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/bunnyquery.js CHANGED
@@ -857,18 +857,31 @@ Index the REMAINING windows - one record per row/item, looking at any page image
857
857
  part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
858
858
  });
859
859
  }
860
+ function canonicalizePathForm(value) {
861
+ if (!value) return value;
862
+ try {
863
+ return value.normalize("NFC");
864
+ } catch (e) {
865
+ return value;
866
+ }
867
+ }
860
868
  function linkUnavailableKeyForPath(remotePath) {
861
- return "path:" + (remotePath || "");
869
+ return "path:" + canonicalizePathForm(remotePath || "");
862
870
  }
863
871
  function linkUnavailableKeyForHref(href) {
864
- return "href:" + (href || "");
872
+ var carried = readExpiredAttachmentHref(href);
873
+ if (carried) return linkUnavailableKeyForPath(carried);
874
+ return "href:" + canonicalizePathForm(href || "");
865
875
  }
866
876
  function linkUnavailableKeysForPath(remotePath) {
867
877
  if (!remotePath) return [];
868
- return [
878
+ var keys = [
869
879
  linkUnavailableKeyForPath(remotePath),
870
880
  linkUnavailableKeyForHref(buildDisplayExpiredAttachmentHref(remotePath))
871
881
  ];
882
+ return keys.filter(function(k, i) {
883
+ return keys.indexOf(k) === i;
884
+ });
872
885
  }
873
886
  function isLinkUnavailable(link, map) {
874
887
  if (!link || !map) return false;
@@ -1247,7 +1260,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1247
1260
  if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
1248
1261
  if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
1249
1262
  if (!preview) return "<a " + attrs.join(" ") + ">" + escapeInlineHtml(labelText) + "</a>";
1250
- return "<a " + attrs.join(" ") + '><img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '" data-bq-img-path="' + escapeInlineHtml(link.remotePath || "") + '" data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : "") + '" loading="lazy" decoding="async"><span class="bq-loader" data-bq-img-loader="1"></span><span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + "</span></a>";
1263
+ return "<a " + attrs.join(" ") + '><img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '" data-bq-img-path="' + escapeInlineHtml(link.remotePath || "") + '" data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : "") + '" decoding="async"><span class="bq-loader" data-bq-img-loader="1"></span><span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + "</span></a>";
1251
1264
  }
1252
1265
 
1253
1266
  // src/engine/image_preview.ts
@@ -1327,31 +1340,48 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1327
1340
  var warm = peekImagePreviewUrl(ctx, path);
1328
1341
  if (warm) {
1329
1342
  img.setAttribute("src", warm);
1343
+ notifyLayoutChange(img, ctx, path);
1330
1344
  return;
1331
1345
  }
1332
1346
  resolveImagePreviewUrl(ctx, path, type).then(function(url) {
1333
1347
  if (img.getAttribute("data-bq-img-state") !== "loading") return;
1334
1348
  img.setAttribute("src", url);
1349
+ notifyLayoutChange(img, ctx, path);
1335
1350
  }, function(e) {
1336
1351
  img.setAttribute("data-bq-img-state", "error");
1352
+ notifyLayoutChange(img, ctx, path);
1337
1353
  if (ctx.onError) ctx.onError(path, e);
1338
1354
  });
1339
1355
  }
1340
1356
  function onImageError(img, ctx, path, type) {
1341
1357
  if (img.getAttribute("data-bq-img-retry") === "1") {
1342
1358
  img.setAttribute("data-bq-img-state", "error");
1359
+ notifyLayoutChange(img, ctx, path);
1343
1360
  if (ctx.onError) ctx.onError(path, new Error("image preview failed to load"));
1344
1361
  return;
1345
1362
  }
1346
1363
  img.setAttribute("data-bq-img-retry", "1");
1347
1364
  img.removeAttribute("src");
1365
+ notifyLayoutChange(img, ctx, path);
1348
1366
  resolveImagePreviewUrl(ctx, path, type, true).then(function(url) {
1349
1367
  img.setAttribute("src", url);
1368
+ notifyLayoutChange(img, ctx, path);
1350
1369
  }, function(e) {
1351
1370
  img.setAttribute("data-bq-img-state", "error");
1371
+ notifyLayoutChange(img, ctx, path);
1352
1372
  if (ctx.onError) ctx.onError(path, e);
1353
1373
  });
1354
1374
  }
1375
+ function notifyLayoutChange(img, ctx, path) {
1376
+ if (!ctx.onLayoutChange) return;
1377
+ ctx.onLayoutChange(previewLayoutBox(img), path);
1378
+ }
1379
+ var PREVIEW_LAYOUT_BOX_SELECTOR = "a.bq-link-button.is-image-preview";
1380
+ function previewLayoutBox(img) {
1381
+ var closest = img.closest;
1382
+ if (typeof closest !== "function") return img;
1383
+ return closest.call(img, PREVIEW_LAYOUT_BOX_SELECTOR) || img;
1384
+ }
1355
1385
 
1356
1386
  // src/engine/time.ts
1357
1387
  function wallClockNow() {
@@ -2389,6 +2419,327 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2389
2419
  }
2390
2420
  return { messages: mapped, runningItemIds };
2391
2421
  }
2422
+ function shouldRescueInFlightMessage(m, ctx) {
2423
+ if (!m) return false;
2424
+ if (m.isBackgroundTask) return false;
2425
+ if (m._ownerKey !== void 0 && ctx.loadKey !== void 0 && m._ownerKey !== ctx.loadKey) return false;
2426
+ if (m._serverItemId && ctx.hasServerId(m._serverItemId)) return false;
2427
+ if (m._stageId) return true;
2428
+ if (!m._serverItemId && ctx.pageHasPendingAssistant) return false;
2429
+ if (m.isSendingToServer || m.isPendingQueued || m.isPendingInProcess || m.isPending) return true;
2430
+ if (ctx.sending && m.role === "user") {
2431
+ var next = ctx.next;
2432
+ if (!next || next.isBackgroundTask || !next.isPending) return false;
2433
+ return next._serverItemId === void 0 || next._serverItemId === m._serverItemId;
2434
+ }
2435
+ return false;
2436
+ }
2437
+
2438
+ // src/engine/scroll_anchor.ts
2439
+ var ROW_KEY_ATTR = "data-row-key";
2440
+ var ROW_POS_ATTR = "data-row-pos";
2441
+ var MAX_ALTS = 2;
2442
+ var ALT_SCAN_LIMIT = 64;
2443
+ var UNKNOWN_ROW_POS = "\0?";
2444
+ function createScrollAnchor(options) {
2445
+ var held = null;
2446
+ var seen = typeof WeakMap === "function" ? /* @__PURE__ */ new WeakMap() : null;
2447
+ function capture() {
2448
+ var box = options.getBox();
2449
+ if (!box || options.isStuck()) return null;
2450
+ var boxTop = box.getBoundingClientRect().top;
2451
+ var kids = box.children;
2452
+ var fallback = null;
2453
+ var fallbackAt = -1;
2454
+ for (var i = 0; i < kids.length; i++) {
2455
+ var el = kids[i];
2456
+ if (!el || typeof el.getAttribute !== "function") continue;
2457
+ var key = el.getAttribute(ROW_KEY_ATTR);
2458
+ if (!key) continue;
2459
+ var top = el.getBoundingClientRect().top - boxTop;
2460
+ if (top + el.offsetHeight <= 0) continue;
2461
+ if (top >= box.clientHeight) break;
2462
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
2463
+ var pos = rawPos === null ? null : rawPos || UNKNOWN_ROW_POS;
2464
+ var cand = {
2465
+ key,
2466
+ top,
2467
+ pos,
2468
+ scrollTop: box.scrollTop,
2469
+ scrollHeight: box.scrollHeight,
2470
+ el
2471
+ };
2472
+ if (rawPos === null) {
2473
+ cand.alts = collectAlts(box, boxTop, i + 1);
2474
+ return cand;
2475
+ }
2476
+ if (!fallback) {
2477
+ fallback = cand;
2478
+ fallbackAt = i;
2479
+ }
2480
+ }
2481
+ if (fallback) {
2482
+ fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
2483
+ return fallback;
2484
+ }
2485
+ return {
2486
+ key: null,
2487
+ top: 0,
2488
+ pos: null,
2489
+ scrollTop: box.scrollTop,
2490
+ scrollHeight: box.scrollHeight,
2491
+ el: null
2492
+ };
2493
+ }
2494
+ function collectAlts(box, boxTop, from, ordinaryOnly) {
2495
+ var out = [];
2496
+ var kids = box.children;
2497
+ var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
2498
+ for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
2499
+ var el = kids[i];
2500
+ if (!el || typeof el.getAttribute !== "function") continue;
2501
+ var key = el.getAttribute(ROW_KEY_ATTR);
2502
+ if (!key) continue;
2503
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
2504
+ if (ordinaryOnly && rawPos !== null) continue;
2505
+ out.push({
2506
+ key,
2507
+ top: el.getBoundingClientRect().top - boxTop,
2508
+ pos: rawPos === null ? null : rawPos || UNKNOWN_ROW_POS,
2509
+ el
2510
+ });
2511
+ }
2512
+ return out.length ? out : void 0;
2513
+ }
2514
+ function findRow(box, anchor) {
2515
+ var el = anchor.el;
2516
+ if (el && el.parentNode === box) return el;
2517
+ if (!anchor.key) return null;
2518
+ var kids = box.children;
2519
+ for (var i = 0; i < kids.length; i++) {
2520
+ var kid = kids[i];
2521
+ if (!kid || typeof kid.getAttribute !== "function") continue;
2522
+ if (kid.getAttribute(ROW_KEY_ATTR) === anchor.key) return kid;
2523
+ }
2524
+ return null;
2525
+ }
2526
+ var sawFrozen = false;
2527
+ var parked = null;
2528
+ var parkedStuck = false;
2529
+ var returning = false;
2530
+ var wroteTop = -1;
2531
+ var restoreExact = true;
2532
+ var restorePinned = false;
2533
+ function frozen() {
2534
+ var f = !!options.isFrozen && options.isFrozen();
2535
+ if (f) sawFrozen = true;
2536
+ return f;
2537
+ }
2538
+ function restore(anchor, unbounded) {
2539
+ if (frozen()) return;
2540
+ var box = options.getBox();
2541
+ if (!box || !anchor || options.isStuck()) return;
2542
+ var el = findRow(box, anchor);
2543
+ if (el) {
2544
+ var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
2545
+ if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS && livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
2546
+ }
2547
+ if (el) {
2548
+ var boxTop = box.getBoundingClientRect().top;
2549
+ var delta = el.getBoundingClientRect().top - boxTop - anchor.top;
2550
+ var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
2551
+ if (!unbounded && (delta > slack || delta < -slack)) {
2552
+ lost(box, anchor);
2553
+ return;
2554
+ }
2555
+ var want = box.scrollTop + delta;
2556
+ if (delta >= 1 || delta <= -1) box.scrollTop += delta;
2557
+ wroteTop = box.scrollTop;
2558
+ restorePinned = true;
2559
+ restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
2560
+ held = {
2561
+ key: anchor.key,
2562
+ top: anchor.top,
2563
+ pos: anchor.pos,
2564
+ scrollTop: box.scrollTop,
2565
+ scrollHeight: box.scrollHeight,
2566
+ el,
2567
+ // Carried, not dropped: one successful restore used to disarm the
2568
+ // standbys for every later hold.
2569
+ alts: anchor.alts
2570
+ };
2571
+ return;
2572
+ }
2573
+ var alts = anchor.alts;
2574
+ for (var ai = 0; alts && ai < alts.length; ai++) {
2575
+ var alt = alts[ai];
2576
+ var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
2577
+ if (!ael) continue;
2578
+ if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
2579
+ var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
2580
+ if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
2581
+ }
2582
+ var aboxTop = box.getBoundingClientRect().top;
2583
+ var adelta = ael.getBoundingClientRect().top - aboxTop - alt.top;
2584
+ var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
2585
+ if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
2586
+ var awant = box.scrollTop + adelta;
2587
+ if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
2588
+ wroteTop = box.scrollTop;
2589
+ restorePinned = true;
2590
+ restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
2591
+ held = {
2592
+ key: alt.key,
2593
+ top: alt.top,
2594
+ pos: alt.pos,
2595
+ scrollTop: box.scrollTop,
2596
+ scrollHeight: box.scrollHeight,
2597
+ el: ael,
2598
+ alts: alts.slice(ai + 1)
2599
+ };
2600
+ return;
2601
+ }
2602
+ lost(box, anchor);
2603
+ }
2604
+ function lost(box, anchor) {
2605
+ held = null;
2606
+ var grew = box.scrollHeight - anchor.scrollHeight;
2607
+ if (grew > 0) {
2608
+ box.scrollTop = anchor.scrollTop + grew;
2609
+ wroteTop = box.scrollTop;
2610
+ return;
2611
+ }
2612
+ if (options.rawFallback) {
2613
+ box.scrollTop = anchor.scrollTop;
2614
+ wroteTop = box.scrollTop;
2615
+ }
2616
+ }
2617
+ function preserve(mutate) {
2618
+ var anchor = capture();
2619
+ var result = mutate();
2620
+ restore(anchor);
2621
+ return result;
2622
+ }
2623
+ function remember() {
2624
+ var b0 = options.getBox();
2625
+ if (returning && b0 && b0.scrollTop !== wroteTop) {
2626
+ parked = null;
2627
+ parkedStuck = false;
2628
+ returning = false;
2629
+ }
2630
+ if (frozen()) return;
2631
+ held = capture();
2632
+ }
2633
+ function hold() {
2634
+ if (frozen()) return;
2635
+ if (sawFrozen || returning) {
2636
+ settleReturn();
2637
+ return;
2638
+ }
2639
+ var box = options.getBox();
2640
+ if (!box || options.isStuck()) {
2641
+ held = null;
2642
+ return;
2643
+ }
2644
+ if (!held) {
2645
+ held = capture();
2646
+ return;
2647
+ }
2648
+ if (box.scrollTop !== held.scrollTop) {
2649
+ held = capture();
2650
+ return;
2651
+ }
2652
+ restore(held);
2653
+ }
2654
+ function absorb(el) {
2655
+ if (!el) return;
2656
+ var box = options.getBox();
2657
+ if (!box) return;
2658
+ var h = el.offsetHeight;
2659
+ var prev = seen ? seen.get(el) : void 0;
2660
+ if (seen) seen.set(el, h);
2661
+ if (options.isStuck()) return;
2662
+ if (prev === void 0) prev = 0;
2663
+ var delta = h - prev;
2664
+ if (delta === 0) return;
2665
+ if (frozen()) {
2666
+ foldFrozenGrowth(box, el, delta);
2667
+ return;
2668
+ }
2669
+ if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
2670
+ box.scrollTop += delta;
2671
+ wroteTop = box.scrollTop;
2672
+ if (held) held = capture();
2673
+ }
2674
+ function park() {
2675
+ parked = capture();
2676
+ parkedStuck = !!options.isStuck();
2677
+ returning = true;
2678
+ }
2679
+ function pinBottom() {
2680
+ var box = options.getBox();
2681
+ if (!box) return;
2682
+ box.scrollTop = box.scrollHeight;
2683
+ wroteTop = box.scrollTop;
2684
+ }
2685
+ function settleReturn() {
2686
+ if (frozen()) return false;
2687
+ sawFrozen = false;
2688
+ if (parkedStuck) {
2689
+ pinBottom();
2690
+ return true;
2691
+ }
2692
+ var target = parked || held;
2693
+ restoreExact = true;
2694
+ restorePinned = false;
2695
+ restore(target, true);
2696
+ parked = !restorePinned || !restoreExact ? target : null;
2697
+ returning = !!parked;
2698
+ return false;
2699
+ }
2700
+ function isReturning() {
2701
+ return returning;
2702
+ }
2703
+ function thaw() {
2704
+ settleReturn();
2705
+ }
2706
+ function foldFrozenGrowth(box, el, delta) {
2707
+ var elTop = el.getBoundingClientRect().top;
2708
+ foldInto(box, held, elTop, delta);
2709
+ foldInto(box, parked, elTop, delta);
2710
+ }
2711
+ function foldInto(box, a, elTop, delta) {
2712
+ if (!a || a.top >= 0) return;
2713
+ var rowEl = findRow(box, a);
2714
+ if (!rowEl) return;
2715
+ var within = elTop - rowEl.getBoundingClientRect().top;
2716
+ if (within < 0 || within >= rowEl.offsetHeight) return;
2717
+ if (within >= -a.top) return;
2718
+ a.top -= delta;
2719
+ a.scrollHeight += delta;
2720
+ }
2721
+ function forget() {
2722
+ held = null;
2723
+ parked = null;
2724
+ parkedStuck = false;
2725
+ returning = false;
2726
+ }
2727
+ return {
2728
+ capture,
2729
+ restore,
2730
+ preserve,
2731
+ remember,
2732
+ hold,
2733
+ park,
2734
+ settleReturn,
2735
+ isReturning,
2736
+ pinBottom,
2737
+ isFrozen: frozen,
2738
+ thaw,
2739
+ absorb,
2740
+ forget
2741
+ };
2742
+ }
2392
2743
 
2393
2744
  // src/engine/viewport_fill.ts
2394
2745
  var HISTORY_FILL_SLACK_PX = 64;
@@ -2500,6 +2851,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2500
2851
  var LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5e3;
2501
2852
  var INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1e3;
2502
2853
  var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
2854
+ var EARLY_PROBE_SCHEDULE_MS = [400, 900, 1700];
2503
2855
  var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
2504
2856
  var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
2505
2857
  var INDEXING_DRAIN_IDLE_LOOKS = 2;
@@ -2895,7 +3247,93 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2895
3247
  *
2896
3248
  * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
2897
3249
  * poll simply cannot be stopped and is left running — see pausePolling.
3250
+ *
3251
+ * (This block documents _trackPoll, further down. The two methods below sit between it
3252
+ * and its subject.)
3253
+ */
3254
+ /**
3255
+ * Foreground poll with an early-probe race.
3256
+ *
3257
+ * skapi's poll() is a bare setInterval(fn, latency) with NO check at t=0, so the earliest a
3258
+ * reply can be observed is one full POLL_INTERVAL (3s) after dispatch. For a long generation
3259
+ * that granularity is free. For a SHORT one it is nearly pure dead time: a greeting that the
3260
+ * provider finishes in 1s still waits until the 3s tick, which measured as a large share of a
3261
+ * 5s "yo" round trip.
3262
+ *
3263
+ * So keep the 3s interval as the steady state, and additionally point-look-up the item a few
3264
+ * times early, on a widening schedule. Whichever answers first wins and the other is stopped.
3265
+ * The probe uses the csrHistoryItemLookup hook both clients already implement; without it this
3266
+ * degrades to exactly the old behaviour.
3267
+ *
3268
+ * FOREGROUND ONLY. Background indexing polls keep the flat cadence: nobody is watching them,
3269
+ * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
3270
+ * the request budget the cap exists to protect.
2898
3271
  */
3272
+ attachForegroundPoll(source, itemId, opts) {
3273
+ return this._fgPollWithEarlyProbe(source, itemId, opts);
3274
+ }
3275
+ _fgPollWithEarlyProbe(source, itemId, opts) {
3276
+ var base = source.poll(Object.assign({ latency: POLL_INTERVAL }, opts || {}));
3277
+ var lookup = chatEngineConfig().csrHistoryItemLookup;
3278
+ var ident = this.host.getIdentity();
3279
+ var platform = ident && ident.platform;
3280
+ if (!lookup || !itemId || !ident || !ident.projectId || platform !== "claude" && platform !== "openai") {
3281
+ return base;
3282
+ }
3283
+ var settled = false;
3284
+ var timers = [];
3285
+ var clearProbes = function() {
3286
+ for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
3287
+ timers = [];
3288
+ };
3289
+ var stopBase = base && typeof base.stop === "function" ? base.stop.bind(base) : null;
3290
+ var raced = new Promise(function(resolve, reject) {
3291
+ base.then(function(res) {
3292
+ if (settled) return;
3293
+ settled = true;
3294
+ clearProbes();
3295
+ resolve(res);
3296
+ }, function(err) {
3297
+ if (settled) return;
3298
+ settled = true;
3299
+ clearProbes();
3300
+ reject(err);
3301
+ });
3302
+ var fullId = buildHistoryItemFullId(platform, ident.projectId, itemId);
3303
+ EARLY_PROBE_SCHEDULE_MS.forEach(function(delay) {
3304
+ timers.push(setTimeout(function() {
3305
+ if (settled) return;
3306
+ Promise.resolve(lookup(fullId, ident.projectId, ident.owner)).then(function(body) {
3307
+ if (settled || !body || typeof body !== "object") return;
3308
+ if (body.status === "pending" || body.status === "running") return;
3309
+ if (isPollStopped(body)) return;
3310
+ settled = true;
3311
+ clearProbes();
3312
+ if (stopBase) {
3313
+ try {
3314
+ stopBase();
3315
+ } catch (e) {
3316
+ }
3317
+ }
3318
+ if (opts && typeof opts.onResponse === "function") {
3319
+ try {
3320
+ opts.onResponse(body);
3321
+ } catch (e) {
3322
+ }
3323
+ }
3324
+ resolve(body);
3325
+ }, function() {
3326
+ });
3327
+ }, delay));
3328
+ });
3329
+ });
3330
+ raced.stop = function() {
3331
+ settled = true;
3332
+ clearProbes();
3333
+ if (stopBase) stopBase();
3334
+ };
3335
+ return raced;
3336
+ }
2899
3337
  _trackPoll(id, kind, p) {
2900
3338
  var stop = p && typeof p.stop === "function" ? p.stop.bind(p) : void 0;
2901
3339
  if (!stop) {
@@ -3071,6 +3509,68 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3071
3509
  startKeyHistory: this.state.historyStartKeyHistory.slice()
3072
3510
  };
3073
3511
  }
3512
+ /**
3513
+ * Give the immediate-send pair the server's id for their turn, the moment the
3514
+ * dispatch learns it.
3515
+ *
3516
+ * WHY THIS EXISTS. An immediate send pushes its user bubble and its
3517
+ * "Thinking..." placeholder locally, and until now neither ever carried a
3518
+ * _serverItemId — only the QUEUED path stamped one, off its ack. So for the
3519
+ * whole life of the turn there was no way to tell the local copy and the
3520
+ * server's copy of the SAME turn apart, and the history merge fell back to a
3521
+ * heuristic: rescue the local pair unless the freshly-fetched page happens to
3522
+ * contain a pending assistant.
3523
+ *
3524
+ * That heuristic has a hole exactly one poll interval wide. The server settles
3525
+ * the request; for up to POLL_INTERVAL the client has not noticed, so
3526
+ * `state.sending` is still true and the local pair is still on screen — while a
3527
+ * history fetch issued in that window returns the turn ALREADY SETTLED, with no
3528
+ * pending assistant in it. The rescue then re-appends the local pair below the
3529
+ * server's copy (the question, twice), and when the poll finally resolves,
3530
+ * typewriteLatestReply writes the answer into the rescued placeholder because it
3531
+ * is the only pending assistant left (the answer, twice). updateHistoryCache
3532
+ * persists the result, so it survives every later visit.
3533
+ *
3534
+ * Navigating away while waiting and coming back is what lands a fetch in that
3535
+ * window: a remount runs refreshGate -> a fresh first page, at an arbitrary
3536
+ * moment relative to the 3s poll.
3537
+ *
3538
+ * With the id on the bubbles, both clients' rescue loops skip them through the
3539
+ * dedup they already have (`_serverItemId is in this page`), the reply the
3540
+ * dispatch caches inherits the id too, and nothing needs a new special case.
3541
+ *
3542
+ * Matched by _localId, never by index: a file's indexing rows are spliced in
3543
+ * above these bubbles while the request is in flight.
3544
+ */
3545
+ _stampTurnWithItemId(key, userLid, placeholderLid, itemId) {
3546
+ if (!itemId) return;
3547
+ var changed = false;
3548
+ var stamp = function(list) {
3549
+ var hit = false;
3550
+ for (var i = 0; i < list.length; i++) {
3551
+ var m = list[i];
3552
+ if (!m || !m._localId) continue;
3553
+ if (m._localId !== userLid && m._localId !== placeholderLid) continue;
3554
+ if (m._serverItemId === itemId) continue;
3555
+ list[i] = Object.assign({}, m, { _serverItemId: itemId });
3556
+ hit = true;
3557
+ }
3558
+ return hit;
3559
+ };
3560
+ changed = stamp(this.state.messages);
3561
+ var cached = key ? this.aiChatHistoryCache[key] : void 0;
3562
+ if (cached) {
3563
+ var msgs = cached.messages.slice();
3564
+ if (stamp(msgs)) {
3565
+ this.aiChatHistoryCache[key] = {
3566
+ messages: msgs,
3567
+ endOfList: cached.endOfList,
3568
+ startKeyHistory: cached.startKeyHistory
3569
+ };
3570
+ }
3571
+ }
3572
+ if (changed) this.host.notify();
3573
+ }
3074
3574
  /**
3075
3575
  * Land a resolved reply in the history cache of a chat that is NOT currently
3076
3576
  * visible, without touching state.messages. Mirrors the cache-only path in
@@ -3152,8 +3652,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3152
3652
  if (initial.id) {
3153
3653
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
3154
3654
  dispatchItemId = initial.id;
3655
+ if (typeof params.onItemId === "function") params.onItemId(initial.id);
3155
3656
  }
3156
- var dp = initial.poll({ latency: POLL_INTERVAL });
3657
+ var dp = self._fgPollWithEarlyProbe(initial, initial.id);
3157
3658
  if (initial.id) self._trackPoll(initial.id, "fg", dp);
3158
3659
  return dp;
3159
3660
  }
@@ -3178,28 +3679,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3178
3679
  }).then(function(result) {
3179
3680
  delete self.pendingAgentRequests[params.key];
3180
3681
  if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
3181
- var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
3182
3682
  var reply = { role: "assistant", content: result.content, isError: result.isError };
3183
- var msgs = existing.messages.slice();
3184
- var idx = -1;
3185
- for (var i = msgs.length - 1; i >= 0; i--) {
3186
- var m = msgs[i];
3187
- if (m && m.isPending && m.role === "assistant" && !m.isBackgroundTask) {
3188
- idx = i;
3189
- break;
3190
- }
3191
- }
3192
- if (idx !== -1) {
3193
- reply._serverItemId = msgs[idx]._serverItemId;
3194
- msgs[idx] = reply;
3195
- } else {
3196
- msgs.push(reply);
3197
- }
3198
- self.aiChatHistoryCache[params.key] = {
3199
- messages: msgs,
3200
- endOfList: existing.endOfList,
3201
- startKeyHistory: existing.startKeyHistory
3202
- };
3683
+ if (dispatchItemId) reply._serverItemId = dispatchItemId;
3684
+ self._applyReplyToCache(params.key, reply, dispatchItemId);
3203
3685
  return result;
3204
3686
  });
3205
3687
  this.pendingAgentRequests[params.key] = run;
@@ -3588,8 +4070,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3588
4070
  }
3589
4071
  this.host.notify();
3590
4072
  this.updateHistoryCache();
3591
- this.host.scrollToBottom(true);
4073
+ this.scrollForDispatch(stageId);
3592
4074
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
4075
+ var capturedQueuedLid = queuedBubble._localId;
3593
4076
  Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner)).then(function(result) {
3594
4077
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
3595
4078
  return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
@@ -3601,8 +4084,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3601
4084
  self.state.messages[sendingIdx] = upd;
3602
4085
  self.host.notify();
3603
4086
  }
4087
+ if (serverId) self._stampTurnWithItemId(capturedKey, capturedQueuedLid, void 0, serverId);
3604
4088
  if (result && result.poll && (result.status === "pending" || result.status === "running")) {
3605
- var qp = result.poll({ latency: POLL_INTERVAL });
4089
+ var qp = self._fgPollWithEarlyProbe(result, serverId);
3606
4090
  if (serverId) self._trackPoll(serverId, "fg", qp);
3607
4091
  return qp.then(function(res) {
3608
4092
  if (isPollStopped(res)) return;
@@ -3618,7 +4102,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3618
4102
  return;
3619
4103
  }
3620
4104
  var immediateUser = { role: "user", content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
3621
- var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
4105
+ var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _localId: this._newLocalId(), ...key ? { _ownerKey: key } : {} };
3622
4106
  var iStage = this._stageIndex(this.state.messages, stageId);
3623
4107
  if (iStage !== -1) {
3624
4108
  var iEx = this.state.messages[iStage];
@@ -3632,7 +4116,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3632
4116
  this.host.notify();
3633
4117
  this.updateHistoryCache();
3634
4118
  this.state.sending = true;
3635
- this.host.scrollToBottom(true);
4119
+ this.scrollForDispatch(stageId);
3636
4120
  var historyForLlm = this.state.messages.filter(function(m) {
3637
4121
  if (m === immediateUser) return false;
3638
4122
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
@@ -3645,6 +4129,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3645
4129
  projectId: id.projectId,
3646
4130
  history: historyForLlm
3647
4131
  });
4132
+ var immediateUserLid = immediateUser._localId, immediatePlaceholderLid = immediatePlaceholder._localId;
3648
4133
  var run = this.dispatchAgentRequest({
3649
4134
  key,
3650
4135
  projectId: id.projectId,
@@ -3656,7 +4141,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3656
4141
  boundedMessages: bounded.messages,
3657
4142
  userId: chatQueue,
3658
4143
  extractContent,
3659
- fileUrls
4144
+ fileUrls,
4145
+ // THE fix for a turn rendering twice after navigate-away-and-back. Until
4146
+ // this existed the immediate-send pair carried no server id for its whole
4147
+ // life (only the QUEUED path stamped one, off its ack), so nothing could
4148
+ // tell the local copy and the server's copy of the same turn apart. See
4149
+ // _stampTurnWithItemId.
4150
+ onItemId: function(itemId) {
4151
+ self._stampTurnWithItemId(key, immediateUserLid, immediatePlaceholderLid, itemId);
4152
+ }
3660
4153
  });
3661
4154
  Promise.resolve(run).catch(function() {
3662
4155
  }).then(function() {
@@ -3667,6 +4160,28 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3667
4160
  });
3668
4161
  });
3669
4162
  }
4163
+ /**
4164
+ * Scroll for a dispatch that is going out NOW, but never for one arriving late.
4165
+ *
4166
+ * A turn with attachments does not dispatch when the user hits Send: it waits out
4167
+ * its uploads and then its whole indexing chain, which is minutes
4168
+ * (awaitIndexingDrained). By then the reader has very often scrolled up into
4169
+ * history to pass the time, and the forcing scrollToBottom yanked them out of it
4170
+ * — and worse, force-pinned stickToBottom, which no-ops every method on the
4171
+ * scroll anchor and re-arms the queue-detect poll whose only bail is
4172
+ * !stickToBottom.
4173
+ *
4174
+ * `stageId` is the exact marker for that case: only the attachment path ever
4175
+ * produces one. The gesture itself was already paid for at stage time, where
4176
+ * stageOutgoingMessage forces the scroll while the user is still looking at the
4177
+ * composer. Deliberately NOT gated on "did we find the staged bubble" — a remount
4178
+ * rebuilds from the cache and the turn is appended rather than replaced, which is
4179
+ * just as late and just as unrequested.
4180
+ */
4181
+ scrollForDispatch(stageId) {
4182
+ if (stageId) this.host.scrollToBottomIfSticky(true);
4183
+ else this.host.scrollToBottom(true);
4184
+ }
3670
4185
  promoteNextBgQueuedToRunning() {
3671
4186
  if (this.state.messages.some(function(m) {
3672
4187
  return m.isPending && m.role === "assistant" && m.isBackgroundTask;
@@ -3792,6 +4307,29 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3792
4307
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
3793
4308
  else this.state.messages.push(msg);
3794
4309
  }
4310
+ /**
4311
+ * The server's OWN copy of this turn is already on screen.
4312
+ *
4313
+ * A first-page fetch can land between the server settling the item and this
4314
+ * poll's tick, and now that the local bubbles carry the item id
4315
+ * (_stampTurnWithItemId) the rescue correctly drops them and renders the
4316
+ * server's settled pair instead. There is then nothing left to resolve: the
4317
+ * -1 fallback would push the answer in a SECOND time at the bottom of the list,
4318
+ * and the positional fallbacks would hijack some other turn's bubble.
4319
+ *
4320
+ * The USER bubble counts, not just a settled assistant: an item whose answer is
4321
+ * empty produces no assistant bubble at all in the mapper, and that variant
4322
+ * would otherwise still bottom-push "No text response received...". While a turn
4323
+ * is genuinely live its user bubble is always pending (the queued branch sets
4324
+ * isPendingQueued, promoteNextQueuedToRunning sets isPendingInProcess), so this
4325
+ * cannot fire early.
4326
+ */
4327
+ _turnAlreadyRendered(serverId) {
4328
+ if (!serverId) return false;
4329
+ return this.state.messages.some(function(m) {
4330
+ return m._serverItemId === serverId && !m.isPending && !m.isPendingQueued && !m.isPendingInProcess;
4331
+ });
4332
+ }
3795
4333
  onQueuedSendResponse(_composed, response, platform, serverId, ownerKey) {
3796
4334
  if (serverId) this.historyItemPolls.delete(serverId);
3797
4335
  if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
@@ -3800,6 +4338,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3800
4338
  if (serverId) this.cancelledServerIds.delete(serverId);
3801
4339
  return;
3802
4340
  }
4341
+ if (this._turnAlreadyRendered(serverId)) {
4342
+ if (serverId) this.cancelledServerIds.delete(serverId);
4343
+ this.host.notify();
4344
+ this.updateHistoryCache();
4345
+ return;
4346
+ }
3803
4347
  var targetIdx = this.resolveQueuedUserBubble(serverId);
3804
4348
  if (targetIdx === void 0) {
3805
4349
  this.host.notify();
@@ -3841,6 +4385,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3841
4385
  if (serverId) this.cancelledServerIds.delete(serverId);
3842
4386
  return;
3843
4387
  }
4388
+ if (this._turnAlreadyRendered(serverId)) {
4389
+ if (serverId) this.cancelledServerIds.delete(serverId);
4390
+ this.host.notify();
4391
+ this.updateHistoryCache();
4392
+ return;
4393
+ }
3844
4394
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
3845
4395
  if (isNotExists) {
3846
4396
  var userIdx = serverId ? this.state.messages.findIndex(function(m) {
@@ -5102,21 +5652,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5102
5652
  var rescued = [];
5103
5653
  for (var ri = 0; ri < self.state.messages.length; ri++) {
5104
5654
  var mm = self.state.messages[ri];
5105
- if (mm.isBackgroundTask) continue;
5106
- if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
5107
- if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
5108
- if (!mm._serverItemId) {
5109
- if (mm._stageId) {
5110
- rescued.push(mm);
5111
- continue;
5112
- }
5113
- if (mappedHasPendingAssistant) continue;
5114
- if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
5115
- else if (self.state.sending && mm.role === "user") {
5116
- var next = self.state.messages[ri + 1];
5117
- if (next && !next.isBackgroundTask && next.isPending && !next._serverItemId) rescued.push(mm);
5118
- }
5119
- }
5655
+ if (shouldRescueInFlightMessage(mm, {
5656
+ hasServerId: function(sid) {
5657
+ return !!serverIds[sid];
5658
+ },
5659
+ pageHasPendingAssistant: mappedHasPendingAssistant,
5660
+ sending: !!self.state.sending,
5661
+ next: self.state.messages[ri + 1],
5662
+ loadKey
5663
+ })) rescued.push(mm);
5120
5664
  }
5121
5665
  var oldestInPage1 = void 0;
5122
5666
  mapped.forEach(function(m) {
@@ -5167,6 +5711,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5167
5711
  keptOlderPages = prependOlder.length > 0 || interleave.length > 0;
5168
5712
  self.state.messages = prependOlder.length ? prependOlder.concat(page1) : page1;
5169
5713
  rescued.forEach(function(m) {
5714
+ if (m._serverItemId && self.state.messages.some(function(x) {
5715
+ return x._serverItemId === m._serverItemId && x.role === m.role;
5716
+ })) return;
5170
5717
  self.state.messages.push(m);
5171
5718
  });
5172
5719
  if (Object.keys(locallyCancelled).length) {
@@ -5291,6 +5838,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5291
5838
  releaseBgFlag();
5292
5839
  self.updateHistoryCache();
5293
5840
  self.host.notify();
5841
+ if (self.host.settleScroll) self.host.settleScroll();
5294
5842
  }, function() {
5295
5843
  releaseBgFlag();
5296
5844
  self.host.notify();
@@ -5318,8 +5866,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5318
5866
  if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
5319
5867
  if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
5320
5868
  var capturedId = item.id;
5321
- var pp = item.poll({
5322
- latency: POLL_INTERVAL,
5869
+ var isBg = !!(item._isBgTask || item._isOnBgQueue);
5870
+ var pollOpts = {
5323
5871
  onResponse: function(response) {
5324
5872
  if (isPollStopped(response)) return;
5325
5873
  self.handleHistoryItemResolution(capturedId, response, platform);
@@ -5334,9 +5882,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5334
5882
  var uIdx = self.state.messages.findIndex(function(m) {
5335
5883
  return m.role === "user" && m._serverItemId === capturedId && !m.isCancelled;
5336
5884
  });
5337
- var isBg = aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask || uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask;
5885
+ var isBg2 = aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask || uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask;
5338
5886
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
5339
- if (!isBg) {
5887
+ if (!isBg2) {
5340
5888
  if (uIdx !== -1) {
5341
5889
  var ex = self.state.messages[uIdx];
5342
5890
  self.state.messages[uIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
@@ -5363,7 +5911,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5363
5911
  self.updateHistoryCache();
5364
5912
  }
5365
5913
  }
5366
- });
5914
+ };
5915
+ var pp = isBg ? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts)) : self._fgPollWithEarlyProbe(item, capturedId, pollOpts);
5367
5916
  self._trackPoll(capturedId, item._isBgTask || item._isOnBgQueue ? "bg" : "fg", pp);
5368
5917
  if (pp && pp.catch) pp.catch(function() {
5369
5918
  });
@@ -5371,7 +5920,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5371
5920
  self.drainBgTaskQueue();
5372
5921
  }
5373
5922
  if (!fetchMore) self.refreshLiveIndexState();
5374
- if (!fetchMore) return self.host.scrollToBottomIfSticky();
5923
+ if (!fetchMore) return self.host.settleScroll ? self.host.settleScroll() : self.host.scrollToBottomIfSticky();
5375
5924
  }).catch(function(err) {
5376
5925
  console.warn("[chat-engine] getChatHistory failed", err);
5377
5926
  }).then(function() {
@@ -5998,7 +6547,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5998
6547
  (function() {
5999
6548
  var MCP_PROD = "https://mcp.broadwayinc.computer";
6000
6549
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
6001
- var BQ_VERSION = "1.8.12" ;
6550
+ var BQ_VERSION = "1.8.14" ;
6002
6551
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
6003
6552
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
6004
6553
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -7678,10 +8227,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
7678
8227
  refreshMessageBubble(i);
7679
8228
  },
7680
8229
  scrollToBottom: function(smooth) {
7681
- return scrollToBottom(smooth);
8230
+ return scrollToBottom();
7682
8231
  },
7683
8232
  scrollToBottomIfSticky: function(smooth) {
7684
- return scrollToBottomIfSticky(smooth);
8233
+ return scrollToBottomIfSticky();
7685
8234
  },
7686
8235
  // A first page can render shorter than the box (a file's every indexing
7687
8236
  // pass folds into ONE row), and a box that cannot scroll never fires the
@@ -7690,6 +8239,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
7690
8239
  onHistoryLoaded: function(fetchMore, token) {
7691
8240
  if (!fetchMore) ensureHistoryFillsViewport(token);
7692
8241
  },
8242
+ settleScroll: function() {
8243
+ settleScrollAfterRefresh();
8244
+ },
7693
8245
  cancelRequest: function(opts) {
7694
8246
  return S.skapi.cancelClientSecretRequest(opts);
7695
8247
  },
@@ -7881,7 +8433,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
7881
8433
  if (job.stageId) session.settleStagedMessage(job.stageId);
7882
8434
  CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
7883
8435
  renderMessages();
7884
- scrollToBottom(true);
8436
+ scrollToBottomIfSticky();
7885
8437
  return null;
7886
8438
  });
7887
8439
  }
@@ -7934,28 +8486,60 @@ Index the REMAINING windows - one record per row/item, looking at any page image
7934
8486
  };
7935
8487
  enqueueAttachmentSend({ text, batchId, stageId, pinned });
7936
8488
  }
7937
- function scrollToBottom(smooth) {
8489
+ function scrollToBottom() {
8490
+ if (chatScrollAnchor.isFrozen()) {
8491
+ CS.stickToBottom = true;
8492
+ chatScrollAnchor.pinBottom();
8493
+ return Promise.resolve();
8494
+ }
7938
8495
  return raf2().then(function() {
7939
8496
  if (!CS.messagesBox) return;
7940
8497
  CS.stickToBottom = true;
7941
- if (smooth) CS.messagesBox.scrollTo({ top: CS.messagesBox.scrollHeight, behavior: "smooth" });
7942
- else CS.messagesBox.scrollTop = CS.messagesBox.scrollHeight;
8498
+ chatScrollAnchor.pinBottom();
7943
8499
  });
7944
8500
  }
7945
- function scrollToBottomIfSticky(smooth) {
8501
+ function scrollToBottomIfSticky() {
7946
8502
  if (!CS.stickToBottom) return Promise.resolve();
8503
+ if (chatScrollAnchor.isFrozen()) {
8504
+ chatScrollAnchor.pinBottom();
8505
+ return Promise.resolve();
8506
+ }
7947
8507
  return raf2().then(function() {
7948
8508
  if (!CS.stickToBottom || !CS.messagesBox) return;
7949
- if (smooth) CS.messagesBox.scrollTo({ top: CS.messagesBox.scrollHeight, behavior: "smooth" });
7950
- else CS.messagesBox.scrollTop = CS.messagesBox.scrollHeight;
8509
+ chatScrollAnchor.pinBottom();
7951
8510
  });
7952
8511
  }
8512
+ function settleScrollAfterRefresh(afterAbsence) {
8513
+ if (afterAbsence || chatScrollAnchor.isReturning()) {
8514
+ if (chatScrollAnchor.settleReturn()) CS.stickToBottom = true;
8515
+ return;
8516
+ }
8517
+ if (CS.stickToBottom) scrollToBottomIfSticky();
8518
+ else chatScrollAnchor.hold();
8519
+ }
8520
+ var lastHistoryScrollTop = 0;
7953
8521
  function onHistoryScroll() {
7954
8522
  if (!CS.messagesBox || CS.chatSettingsOpen) return;
7955
8523
  var el = CS.messagesBox;
7956
- CS.stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 16;
8524
+ if (chatScrollAnchor.isFrozen()) {
8525
+ lastHistoryScrollTop = el.scrollTop;
8526
+ return;
8527
+ }
8528
+ var atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 16;
8529
+ if (!atBottom) CS.stickToBottom = false;
8530
+ else if (el.scrollTop >= lastHistoryScrollTop) CS.stickToBottom = true;
8531
+ lastHistoryScrollTop = el.scrollTop;
8532
+ chatScrollAnchor.remember();
7957
8533
  if (el.scrollTop <= 60) pageOlderHistoryUntilTaller();
7958
8534
  }
8535
+ function onMessagesFontsSettled() {
8536
+ chatScrollAnchor.hold();
8537
+ }
8538
+ function onMessagesImageSettled(e) {
8539
+ var t = e && e.target;
8540
+ if (!t || t.tagName !== "IMG") return;
8541
+ chatScrollAnchor.absorb(previewLayoutBox(t));
8542
+ }
7959
8543
  var _touchStartY = 0;
7960
8544
  function onMessagesWheel(e) {
7961
8545
  if (e.deltaY < 0) CS.stickToBottom = false;
@@ -8288,12 +8872,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8288
8872
  var uid = S.user && S.user.user_id;
8289
8873
  if (uid) body.uid = uid;
8290
8874
  }
8291
- return S.skapi.util.request("get-signed-url", body, reqOpts).then(function(res) {
8875
+ function unwrap(res) {
8292
8876
  var u = typeof res === "string" ? res : res && res.url;
8293
8877
  if (!u) throw new Error("No temporary URL returned.");
8294
8878
  if (/^https?:\/\//i.test(u)) return u;
8295
8879
  return "https://db." + hostDomain() + "/" + u;
8296
- });
8880
+ }
8881
+ return S.skapi.util.request("get-signed-url", body, reqOpts).then(unwrap);
8297
8882
  }
8298
8883
  var ATTACH_ICON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
8299
8884
  var FILE_ICON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
@@ -8841,7 +9426,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8841
9426
  // earlier failure left on this file's chips.
8842
9427
  onLoad: function(path) {
8843
9428
  clearLinkUnavailable(linkUnavailableKeysForPath(path));
8844
- scrollToBottomIfSticky(false);
9429
+ scrollToBottomIfSticky();
9430
+ },
9431
+ // The resizes an <img> makes with no event of its own to announce
9432
+ // them: the src landing, and the src being dropped for a retry (which
9433
+ // collapses an already-painted picture to nothing). load and error are
9434
+ // covered by the listener on the message box, which also catches the
9435
+ // markdown images this module never sees.
9436
+ onLayoutChange: function(img) {
9437
+ chatScrollAnchor.absorb(img);
8845
9438
  },
8846
9439
  // The mint was refused, or the url it minted would not load. Either
8847
9440
  // way there is no url for this file, so the caption chip left behind
@@ -8863,16 +9456,23 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8863
9456
  }
8864
9457
  var refreshedLinkExpiryTimer = null;
8865
9458
  function expireAllRefreshedLinks() {
8866
- for (var k in refreshedExpiredLinkMap) delete refreshedExpiredLinkMap[k];
8867
- for (var u in unavailableLinkMap) delete unavailableLinkMap[u];
9459
+ var changed = false;
9460
+ for (var k in refreshedExpiredLinkMap) {
9461
+ delete refreshedExpiredLinkMap[k];
9462
+ changed = true;
9463
+ }
9464
+ for (var u in unavailableLinkMap) {
9465
+ delete unavailableLinkMap[u];
9466
+ changed = true;
9467
+ }
9468
+ return changed;
8868
9469
  }
8869
9470
  function scheduleNextLinkExpiryBoundary() {
8870
9471
  if (refreshedLinkExpiryTimer) clearTimeout(refreshedLinkExpiryTimer);
8871
9472
  var now = Date.now();
8872
9473
  var next = Math.ceil(now / LINK_REFRESH_WINDOW_MS) * LINK_REFRESH_WINDOW_MS;
8873
9474
  refreshedLinkExpiryTimer = setTimeout(function() {
8874
- expireAllRefreshedLinks();
8875
- renderMessages();
9475
+ if (expireAllRefreshedLinks()) renderMessages();
8876
9476
  scheduleNextLinkExpiryBoundary();
8877
9477
  }, Math.max(1, next - now));
8878
9478
  }
@@ -9608,40 +10208,33 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9608
10208
  function indexGroupAnchorId(group) {
9609
10209
  return group.anchorId || "";
9610
10210
  }
10211
+ var chatScrollAnchor = createScrollAnchor({
10212
+ getBox: function() {
10213
+ return CS.messagesBox;
10214
+ },
10215
+ isStuck: function() {
10216
+ return !!CS.stickToBottom;
10217
+ },
10218
+ // A hidden tab keeps mutating this list - a resumed poll, the head refresh
10219
+ // and its deferred background batch, a settling request - and every one of
10220
+ // those would otherwise move a scroll position nobody is looking at. Freeze
10221
+ // instead, and put the reader back once they can see it (settleScroll).
10222
+ isFrozen: function() {
10223
+ return typeof document !== "undefined" && !!document.hidden;
10224
+ },
10225
+ rawFallback: true
10226
+ });
9611
10227
  function captureScrollAnchor() {
9612
- var box = CS.messagesBox;
9613
- if (!box || CS.stickToBottom) return null;
9614
- var boxTop = box.getBoundingClientRect().top;
9615
- var kids = box.children;
9616
- var fallback = null;
9617
- for (var i = 0; i < kids.length; i++) {
9618
- if (!kids[i].getAttribute) continue;
9619
- var key = kids[i].getAttribute("data-row-key");
9620
- if (!key) continue;
9621
- var top = kids[i].getBoundingClientRect().top - boxTop;
9622
- if (top + kids[i].offsetHeight <= 0) continue;
9623
- var cand = { key, top, scrollTop: box.scrollTop, pos: kids[i].getAttribute("data-row-pos") };
9624
- if (cand.pos === null) return cand;
9625
- if (!fallback) fallback = cand;
9626
- }
9627
- return fallback || { key: null, top: 0, scrollTop: box.scrollTop };
10228
+ return chatScrollAnchor.capture();
9628
10229
  }
9629
10230
  function restoreScrollAnchor(anchor) {
9630
10231
  var box = CS.messagesBox;
9631
10232
  if (!box) return;
9632
- if (!anchor || CS.stickToBottom) {
9633
- box.scrollTop = box.scrollHeight;
10233
+ if (CS.stickToBottom) {
10234
+ chatScrollAnchor.pinBottom();
9634
10235
  return;
9635
10236
  }
9636
- var boxTop = box.getBoundingClientRect().top;
9637
- var kids = box.children;
9638
- for (var i = 0; anchor.key && i < kids.length; i++) {
9639
- if (!kids[i].getAttribute || kids[i].getAttribute("data-row-key") !== anchor.key) continue;
9640
- if (anchor.pos && kids[i].getAttribute("data-row-pos") !== anchor.pos) break;
9641
- box.scrollTop += kids[i].getBoundingClientRect().top - boxTop - anchor.top;
9642
- return;
9643
- }
9644
- box.scrollTop = anchor.scrollTop;
10237
+ chatScrollAnchor.restore(anchor);
9645
10238
  }
9646
10239
  function syncDraftingIndicator() {
9647
10240
  if (!CS.messagesBox) return;
@@ -9684,6 +10277,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9684
10277
  if (CS.loadingHistory && !CS.loadingOlderHistory) {
9685
10278
  CS.messagesBox.appendChild(historyLoadingEl(true));
9686
10279
  syncDraftingIndicator();
10280
+ restoreScrollAnchor(anchor);
9687
10281
  return;
9688
10282
  }
9689
10283
  var emptyStubEls = [];
@@ -9692,12 +10286,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9692
10286
  for (var ge = 0; ge < emptyEntries.length; ge++) {
9693
10287
  if (emptyEntries[ge].kind !== "indexing") continue;
9694
10288
  var sg = emptyEntries[ge].group;
9695
- emptyStubEls.push(buildIndexGroupEl(sg, !!CS.indexGroupsOpen[sg.key]));
10289
+ var stubEl = buildIndexGroupEl(sg, !!CS.indexGroupsOpen[sg.key]);
10290
+ stubEl.setAttribute("data-row-key", "g" + sg.runKey);
10291
+ stubEl.setAttribute("data-row-pos", indexGroupAnchorId(sg));
10292
+ emptyStubEls.push(stubEl);
9696
10293
  }
9697
10294
  } catch (e) {
9698
10295
  }
9699
10296
  for (var gse = 0; gse < emptyStubEls.length; gse++) CS.messagesBox.appendChild(emptyStubEls[gse]);
9700
10297
  syncDraftingIndicator();
10298
+ restoreScrollAnchor(anchor);
9701
10299
  return;
9702
10300
  }
9703
10301
  var rows = buildChatDisplayList(CS.messages, displayListOptions());
@@ -9762,14 +10360,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9762
10360
  if (idx < 0 || idx >= CS.messages.length) return;
9763
10361
  var oldEl = CS.messageEls[idx];
9764
10362
  if (!oldEl || !oldEl.parentNode) return;
9765
- var newEl = buildMessageEl(CS.messages[idx], idx);
9766
- if (oldEl.classList.contains("bq-index-pass")) newEl.classList.add("bq-index-pass");
9767
- oldEl.parentNode.replaceChild(newEl, oldEl);
9768
- CS.messageEls[idx] = newEl;
10363
+ chatScrollAnchor.preserve(function() {
10364
+ var newEl = buildMessageEl(CS.messages[idx], idx);
10365
+ if (oldEl.classList.contains("bq-index-pass")) newEl.classList.add("bq-index-pass");
10366
+ oldEl.parentNode.replaceChild(newEl, oldEl);
10367
+ CS.messageEls[idx] = newEl;
10368
+ });
9769
10369
  hydrateMessageImagePreviews();
9770
10370
  }
9771
10371
  function renderChat() {
9772
10372
  clearImagePreviewCache(S.projectId || "default");
10373
+ chatScrollAnchor.forget();
9773
10374
  for (var uk in unavailableLinkMap) delete unavailableLinkMap[uk];
9774
10375
  CS.messages = [];
9775
10376
  CS.messageEls = [];
@@ -9844,6 +10445,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9844
10445
  box.addEventListener("wheel", onMessagesWheel, { passive: true });
9845
10446
  box.addEventListener("touchstart", onMessagesTouchStart, { passive: true });
9846
10447
  box.addEventListener("touchmove", onMessagesTouchMove, { passive: true });
10448
+ box.addEventListener("load", onMessagesImageSettled, true);
10449
+ box.addEventListener("error", onMessagesImageSettled, true);
10450
+ if (document.fonts && document.fonts.addEventListener) {
10451
+ document.fonts.addEventListener("loadingdone", onMessagesFontsSettled);
10452
+ }
9847
10453
  CS.messagesBox = box;
9848
10454
  var input = h("textarea", { class: "bq-input", rows: "1", placeholder: "Ask anything about: " + (S.serviceName || "your project") });
9849
10455
  CS.inputEl = input;
@@ -9867,7 +10473,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9867
10473
  if (drafting !== CS.drafting) {
9868
10474
  CS.drafting = drafting;
9869
10475
  syncDraftingIndicator();
9870
- scrollToBottomIfSticky(false);
10476
+ scrollToBottomIfSticky();
9871
10477
  }
9872
10478
  });
9873
10479
  input.addEventListener("keydown", function(e) {
@@ -10221,18 +10827,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10221
10827
  S._resizeBound = true;
10222
10828
  window.addEventListener("resize", function() {
10223
10829
  scheduleAttachmentOverflowRecompute();
10224
- scrollToBottomIfSticky(false);
10830
+ scrollToBottomIfSticky();
10225
10831
  ensureHistoryFillsViewport();
10226
10832
  });
10227
10833
  }
10228
10834
  if (!S._visBound && typeof document !== "undefined" && document.addEventListener) {
10229
10835
  S._visBound = true;
10836
+ window.addEventListener("blur", function() {
10837
+ chatScrollAnchor.park();
10838
+ });
10839
+ window.addEventListener("focus", function() {
10840
+ settleScrollAfterRefresh(true);
10841
+ });
10230
10842
  document.addEventListener("visibilitychange", function() {
10231
10843
  if (document.visibilityState === "hidden") {
10844
+ chatScrollAnchor.park();
10232
10845
  if (session && session.pausePolling) session.pausePolling("hidden");
10233
10846
  return;
10234
10847
  }
10235
10848
  if (document.visibilityState === "visible") {
10849
+ settleScrollAfterRefresh(true);
10236
10850
  var refreshed = S.user ? ensureMcpGrantFresh() : null;
10237
10851
  Promise.resolve(refreshed).catch(function() {
10238
10852
  }).then(function() {