dsh-quick-toc 0.7.0 → 0.7.1

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/lib/client.js CHANGED
@@ -128,8 +128,10 @@ window.__ModuleLoader__.load({
128
128
  "search.empty": "没有匹配",
129
129
  "search.hintMore": "向上滚动可加载更早的消息",
130
130
  "search.hintOldest": "已经是最早的消息",
131
+ "hint.autoOff": "上面还有更早的历史(自动加载已关闭,点「未加载」的条目才会加载)",
131
132
  "hint.bottom": "已经到底了",
132
133
  "outline.bottom": "回到底部",
134
+ "outline.backToMine": "回到我滚到的位置",
133
135
  "turn.jumpReply": "跳转到该回合的模型回答开头",
134
136
  "row.end": "跳到本节末尾",
135
137
  "turn.jumpTurn": "跳转到该回合开头",
@@ -164,7 +166,7 @@ window.__ModuleLoader__.load({
164
166
  "settings.fuzzy": "模糊搜索",
165
167
  "settings.hover": "悬停预览卡片",
166
168
  "settings.remember": "记住阅读位置",
167
- "settings.remember.tip": "开启后,重新打开某个会话会回到上次读到的那一轮;位置只保存在这台浏览器里",
169
+ "settings.autoLoad": "自动加载历史",
168
170
  "settings.debug": "在控制台打印诊断日志",
169
171
  "settings.debug.tip": "开启后控制台会打印一行挂载诊断(宿主能力与跳转加载器状态)",
170
172
  "settings.overridden": "已自定义",
@@ -220,8 +222,10 @@ window.__ModuleLoader__.load({
220
222
  "search.empty": "No matches",
221
223
  "search.hintMore": "Scroll up to load earlier messages",
222
224
  "search.hintOldest": "This is the earliest message",
225
+ "hint.autoOff": "There is older history above (auto-loading is off — click an unloaded row to load it)",
223
226
  "hint.bottom": "You have reached the end",
224
227
  "outline.bottom": "Back to the newest entry",
228
+ "outline.backToMine": "Back to where I scrolled to",
225
229
  "turn.jumpReply": "Jump to the start of this turn's model reply",
226
230
  "row.end": "Jump to the end of this section",
227
231
  "turn.jumpTurn": "Jump to the start of this turn",
@@ -258,7 +262,7 @@ window.__ModuleLoader__.load({
258
262
  "settings.fuzzy": "Fuzzy search",
259
263
  "settings.hover": "Hover preview card",
260
264
  "settings.remember": "Remember the reading position",
261
- "settings.remember.tip": "When on, reopening a session returns to the turn you were last reading; the position is stored in this browser only",
265
+ "settings.autoLoad": "Load history automatically",
262
266
  "settings.debug": "Print the mount diagnostic to the console",
263
267
  "settings.debug.tip": "The console then prints one mount line (host capabilities and jump-loader state)",
264
268
  "settings.overridden": "Customized",
@@ -331,7 +335,7 @@ window.__ModuleLoader__.load({
331
335
  // purpose — they describe THIS screen, not the account.
332
336
  var NAMESPACE = "dsh-quick-toc";
333
337
  // Fields backed by the Host settings document; everything else is localStorage.
334
- var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, zoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1 };
338
+ var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, zoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1, autoLoad: 1 };
335
339
  // The schema defaults (mirrored here for the card's per-field reset and for the
336
340
  // one-time import of a 0.5.x install's stored values).
337
341
  var DEFAULTS = {
@@ -339,7 +343,7 @@ window.__ModuleLoader__.load({
339
343
  levels: [1, 2, 3, 4, 5, 6],
340
344
  zoom: 1, handle: 0.5,
341
345
  fuzzy: false, hover: true, lang: "auto", debug: false,
342
- remember: true
346
+ remember: true, autoLoad: true
343
347
  };
344
348
  var PREF_KEYS = {
345
349
  dock: "dsh-quick-toc.dock.v2",
@@ -353,6 +357,7 @@ window.__ModuleLoader__.load({
353
357
  hover: "dsh-quick-toc.hover.v1",
354
358
  lang: "dsh-quick-toc.lang.v1",
355
359
  remember: "dsh-quick-toc.remember.v1",
360
+ autoLoad: "dsh-quick-toc.autoLoad.v1",
356
361
  debug: "dsh-quick-toc.debug"
357
362
  };
358
363
  // Writing a field's schema default through the card IS its reset — a value that
@@ -484,6 +489,8 @@ window.__ModuleLoader__.load({
484
489
  hover: read(PREF_KEYS.hover) !== "0",
485
490
  // absent means "on" too (the reader asked for this to default on)
486
491
  remember: read(PREF_KEYS.remember) !== "0",
492
+ // and so does the history pull that follows the reader through the outline
493
+ autoLoad: read(PREF_KEYS.autoLoad) !== "0",
487
494
  lang: langRaw === "zh" || langRaw === "en" ? langRaw : "auto",
488
495
  debug: read(PREF_KEYS.debug) === "1"
489
496
  };
@@ -500,6 +507,7 @@ window.__ModuleLoader__.load({
500
507
  fuzzy: function (v) { return v ? "1" : "0"; },
501
508
  hover: function (v) { return v ? "1" : "0"; },
502
509
  remember: function (v) { return v ? "1" : "0"; },
510
+ autoLoad: function (v) { return v ? "1" : "0"; },
503
511
  lang: function (v) { return v; },
504
512
  debug: function (v) { return v ? "1" : "0"; }
505
513
  };
@@ -531,6 +539,7 @@ window.__ModuleLoader__.load({
531
539
  fuzzy: v.fuzzy === true,
532
540
  hover: v.hover !== false,
533
541
  remember: v.remember !== false,
542
+ autoLoad: v.autoLoad !== false,
534
543
  debug: v.debug === true
535
544
  };
536
545
  };
@@ -541,7 +550,7 @@ window.__ModuleLoader__.load({
541
550
  return {
542
551
  dock: over.dock, y: local.y, w: local.w, h: local.h,
543
552
  levels: over.levels, zoom: over.zoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
544
- lang: over.lang, remember: over.remember, debug: over.debug
553
+ lang: over.lang, remember: over.remember, debug: over.debug, autoLoad: over.autoLoad
545
554
  };
546
555
  };
547
556
  var notify = function () {
@@ -1573,6 +1582,7 @@ window.__ModuleLoader__.load({
1573
1582
  var openFromEdge = function () {
1574
1583
  if (open) return;
1575
1584
  setEdgeLeaving(true);
1585
+ armRestore("dock"); // the position the reader closed the panel at, if any
1576
1586
  setOpen(true);
1577
1587
  };
1578
1588
  react.useEffect(function () {
@@ -1677,6 +1687,27 @@ window.__ModuleLoader__.load({
1677
1687
  var outlineTouchRef = react.useRef(0); // last time the user touched the outline
1678
1688
  var lastScrollTopRef = react.useRef(0);
1679
1689
  var lastHostClickRef = react.useRef(0); // throttle for the "load earlier" click
1690
+ // Auto-load bookkeeping (see the paging block): at most one pull in flight, a beat
1691
+ // between pulls, and a per-gesture budget so a runaway loop can never swallow the
1692
+ // whole log. The switch itself is mirrored into a ref because a scheduled retry
1693
+ // fires from an older render's closure.
1694
+ var pullBusyRef = react.useRef(false);
1695
+ var autoTimerRef = react.useRef(null);
1696
+ var autoBurstRef = react.useRef(0);
1697
+ var autoBusyRef = react.useRef(0);
1698
+ var autoWatchRef = react.useRef(null); // did the pull we started actually land? (fallback click)
1699
+ var autoProbeRef = react.useRef(0); // throttle for the on-scroll approach probe
1700
+ var autoStampRef = react.useRef(0);
1701
+ var autoLoadRef = react.useRef(true);
1702
+ var panelVisibleRef = react.useRef(true);
1703
+ // The reader's place in the outline: sampled by the scroll handler and right
1704
+ // before any change of our own, restored in a LAYOUT effect (see the paging
1705
+ // block). `anchorHintRef` remembers where the visible boundary was, so finding it
1706
+ // again stays cheap on a list with hundreds of groups.
1707
+ var anchorRef = react.useRef(null);
1708
+ var anchorHintRef = react.useRef(0);
1709
+ // one confirmation loop at a time, cancelled on unmount (see confirmPlace)
1710
+ var keepPlaceRafRef = react.useRef(null);
1680
1711
 
1681
1712
  // ---- "back to the newest row" button ------------------------------------
1682
1713
  // The list can be scrolled far up into history (that is the whole point of
@@ -1713,6 +1744,110 @@ window.__ModuleLoader__.load({
1713
1744
  else el.scrollTop = el.scrollHeight;
1714
1745
  };
1715
1746
 
1747
+ // ---- "back to where I was when I closed this" ---------------------------
1748
+ // The outline re-centres itself on the turn being read (the follow), and opening a surface
1749
+ // lands on that turn — so a reader who had scrolled the outline somewhere of their own
1750
+ // accord finds it somewhere else afterwards, with no way back. The memory is taken AT
1751
+ // CLOSE TIME and kept PER SURFACE (the docked panel and the curtain are different shapes
1752
+ // and are left at different rows): closing the panel remembers the panel's row, closing
1753
+ // the curtain remembers the curtain's, and opening one of them arms a one-shot button in
1754
+ // the top-right corner FOR THAT SURFACE — the two memories are stored apart and can never
1755
+ // overwrite each other. Using the button — or simply scrolling back there yourself —
1756
+ // consumes that surface's memory and fades the button out, and nothing is remembered
1757
+ // again until the next close.
1758
+ //
1759
+ // What is remembered is the ROW that sat at the top AND where it sat, not a bare pixel
1760
+ // offset: the same list is re-laid out between close and open (widths, curtain handoff,
1761
+ // turns that finish loading), so an offset alone would drift off the row the reader was
1762
+ // actually looking at — but the row alone is not enough either. At the bottom of the list
1763
+ // the row on the top edge is usually CLIPPED (its own top sits above the list's), and
1764
+ // measuring "how far is that row from the top" against 0 made the button light up the
1765
+ // moment the reader reopened a list they had left at the bottom — and then walk them up by
1766
+ // that clipping distance. Comparing the row's offset NOW with the offset it had when they
1767
+ // left answers the real question ("is this a different place?"), and with zero drift
1768
+ // nothing is offered and nothing moves.
1769
+ var closedPosRef = react.useRef({ dock: null, sheet: null }); // recorded at close time
1770
+ var livePosRef = react.useRef({ dock: null, sheet: null }); // armed when that surface opens
1771
+ var _sRestore = react.useState(0);
1772
+ var setRestoreTick = _sRestore[1];
1773
+ // Which surface the reader is looking at right now: the button speaks for that one, and
1774
+ // only that one's memory is ever spent.
1775
+ var surfaceNow = function () { return sheetOpen ? "sheet" : "dock"; };
1776
+ var liveTgt = function () { return livePosRef.current[surfaceNow()]; };
1777
+ var spendRestore = function () {
1778
+ var s = surfaceNow();
1779
+ if (livePosRef.current[s] === null) return;
1780
+ livePosRef.current[s] = null;
1781
+ setRestoreTick(function (n) { return n + 1; }); // the button fades out
1782
+ };
1783
+ var noteClosed = function (which) {
1784
+ var el = listRef.current;
1785
+ if (!el) return;
1786
+ var a = anchorAt(el); // the row at the top of the list right now
1787
+ closedPosRef.current[which] = a
1788
+ ? { idx: a.idx, off: a.top - el.getBoundingClientRect().top }
1789
+ : null;
1790
+ };
1791
+ var armRestore = function (which) {
1792
+ var m = closedPosRef.current[which];
1793
+ closedPosRef.current[which] = null; // one shot: this open consumes it
1794
+ livePosRef.current[which] = (m && m.idx !== undefined) ? m : null;
1795
+ if (m && m.idx !== undefined) {
1796
+ // let the restore win over the follow for a moment: otherwise the follow re-aligns
1797
+ // the READING row the instant the panel is back and eats the memory
1798
+ outlineTouchRef.current = Date.now();
1799
+ }
1800
+ setRestoreTick(function (n) { return n + 1; });
1801
+ };
1802
+ // How far that row has drifted from where the reader left it (0 = still the same place)
1803
+ var restoreDrift = function (el) {
1804
+ var tgt = liveTgt();
1805
+ if (!el || !tgt) return null;
1806
+ var node = el.querySelector('[data-group-idx="' + tgt.idx + '"]');
1807
+ if (!node) return null; // that row is gone: nothing to go back to
1808
+ return (node.getBoundingClientRect().top - el.getBoundingClientRect().top) - tgt.off;
1809
+ };
1810
+ var awayFromRestore = function (el) {
1811
+ var d = restoreDrift(el);
1812
+ return d !== null && Math.abs(d) > 40;
1813
+ };
1814
+ var syncBackBtn = function (el) {
1815
+ var d = restoreDrift(el);
1816
+ // the reader brought the row back to where it was by hand: that is the same as using it
1817
+ if (d !== null && Math.abs(d) <= 40) spendRestore();
1818
+ };
1819
+ var backToBrowse = function (e) {
1820
+ if (e && e.stopPropagation) e.stopPropagation();
1821
+ outlineTouchRef.current = Date.now();
1822
+ hoverEnd();
1823
+ var el = listRef.current;
1824
+ var tgt = liveTgt();
1825
+ if (!el || !tgt) return;
1826
+ var node = el.querySelector('[data-group-idx="' + tgt.idx + '"]');
1827
+ if (node) {
1828
+ // put that row back where it SAT (not necessarily at the top): a row the reader left
1829
+ // clipped by 120px goes home clipped by 120px, so the view lands exactly where it was
1830
+ var d = (node.getBoundingClientRect().top - el.getBoundingClientRect().top) - tgt.off;
1831
+ if (Math.abs(d) > 0.5) el.scrollTop = el.scrollTop + d;
1832
+ }
1833
+ syncAtBottom(el);
1834
+ spendRestore();
1835
+ };
1836
+ // The button floats over the LIST, so it has to clear whatever chrome sits above the
1837
+ // list — and that chrome differs between the docked panel (grip bar + header row, plus
1838
+ // the search row when open) and the curtain (its own toolbar). Measuring where the list
1839
+ // actually begins is exact for both, and stays right if the chrome changes height.
1840
+ var _sListTop = react.useState(0);
1841
+ var listTop = _sListTop[0];
1842
+ var setListTop = _sListTop[1];
1843
+ react.useLayoutEffect(function () {
1844
+ var root = panelRootRef.current;
1845
+ var el = listRef.current;
1846
+ if (!root || !el || !root.getBoundingClientRect || !el.getBoundingClientRect) return;
1847
+ var top = Math.round(el.getBoundingClientRect().top - root.getBoundingClientRect().top);
1848
+ if (top > 0 && top !== listTop) setListTop(top);
1849
+ });
1850
+
1716
1851
  // ---- "scroll up to load earlier messages" hint (search mode) -----------
1717
1852
  // Armed by a search when older history is still reachable; dismissed the
1718
1853
  // moment the reader scrolls up (which is when the older page starts loading,
@@ -1958,6 +2093,7 @@ window.__ModuleLoader__.load({
1958
2093
  // re-renders until the curtain is actually gone.
1959
2094
  var closeSheet = function () {
1960
2095
  if (!sheetOpen || sheetTimerRef.current) return;
2096
+ noteClosed("sheet"); // where they were when they shut the curtain
1961
2097
  var el = sheetRef.current;
1962
2098
  if (el && el.classList) {
1963
2099
  el.classList.remove("dqt-sheet-open");
@@ -1975,6 +2111,7 @@ window.__ModuleLoader__.load({
1975
2111
  };
1976
2112
  var openSheet = function () {
1977
2113
  if (sheetOpen || panelAside) return;
2114
+ armRestore("sheet"); // the position the reader closed the curtain at, if any
1978
2115
  if (sheetTimerRef.current) { clearTimeout(sheetTimerRef.current); sheetTimerRef.current = null; }
1979
2116
  // reopening mid-retract: the node still carries the exit class and React
1980
2117
  // would not put the enter class back on its own (that prop never changed)
@@ -2245,82 +2382,408 @@ window.__ModuleLoader__.load({
2245
2382
  var setActiveGroup = _s10[1];
2246
2383
  var activeSigRef = react.useRef("");
2247
2384
 
2248
- // load older outline groups; when everything is loaded, click the
2249
- // conversation's own "load older" button so older turns keep appearing
2385
+ // ---- history paging: what is loaded, what is not, and how to ask ------------
2386
+ // The loader does NOT touch the transcript. It used to nudge it 26px off DSH's
2387
+ // stick-to-bottom line before every pull, to stop DSH's `toBottom` re-pin from
2388
+ // undoing an outline-triggered page-in (0.5.1). That nudge is visible in the middle
2389
+ // column, and it fires from the background loader too, so the reader saw the
2390
+ // conversation move while they were only reading the outline ("I did not click
2391
+ // anything, why did the middle panel scroll up"). It is also no longer needed: the
2392
+ // outline's follow has its own quiet window after any outline gesture
2393
+ // (`outlineTouchRef`), so a re-pin cannot drag the rail to the newest turn any more,
2394
+ // and `glideTo` keeps its own guard for the jumps the reader actually asks for.
2395
+ // The session face carries the host's own bookkeeping — `openState`, `hasMore`
2396
+ // and `loadingOlder` — so the panel never has to infer "is there more?" from a
2397
+ // button it found in the DOM. That inference is exactly what used to stall the
2398
+ // outline: the host renders its "load earlier" button only while `hasMore` and
2399
+ // DISABLES it while a page is in flight, so a scroll-up arriving during a load was
2400
+ // read as "nothing older" — the reader was told they had reached the beginning
2401
+ // with hundreds of turns still above them.
2402
+ var sessionFace = function () {
2403
+ var sessions = hostSessions();
2404
+ var binding = sessions && typeof sessions.binding === "function" ? sessions.binding(sessionId) : null;
2405
+ return binding && binding.session ? binding.session : null;
2406
+ };
2407
+ var faceSettled = function (face) { return face.openState === undefined || face.openState === "open"; };
2408
+ var historyBusy = function (face) { return !!face && face.loadingOlder === true; };
2409
+ // The beginning is reached only when the window says so AND nothing is in flight:
2410
+ // `hasMore` is stale while a page is being pulled (it flips once that page lands),
2411
+ // and reading it during a pull is exactly how "still loading" became "no more
2412
+ // history" for the reader.
2413
+ var historyExhausted = function (face) {
2414
+ return !!face && face.hasMore === false && !historyBusy(face) && faceSettled(face);
2415
+ };
2416
+ // The host's own "load earlier" control. The API above is the normal path, but a
2417
+ // build can expose the session face and still ignore the pull; clicking this is
2418
+ // exactly what the reader would do by hand, so the auto-loader falls back to it.
2419
+ // (The reader asked for precisely this: "when it loads, click that for me".)
2420
+ var viaHostButtonRef = react.useRef(function () { return false; });
2421
+ viaHostButtonRef.current = function () {
2422
+ var btn = findLoadOlderButton();
2423
+ if (!btn || btn.disabled) return false;
2424
+ btn.click();
2425
+ return true;
2426
+ };
2427
+ var viaHostButton = function () { return viaHostButtonRef.current(); };
2428
+ // Extend the window backwards by one host page (~50 messages): cheap and bounded,
2429
+ // the way to walk into older history rather than swallow it.
2430
+ // `interactive` = the reader asked for this (their own gesture in the outline). Only
2431
+ // then do we nudge the TRANSCRIPT off DSH's stick-to-bottom line — that nudge is what
2432
+ // used to make the conversation move while the reader was only reading the outline
2433
+ // ("I did not click anything and the middle panel scrolled up"). A background prefetch
2434
+ // leaves the transcript alone: if the reader is at the newest message, DSH keeps them
2435
+ // there, and if they are mid-transcript the host compensates the prepend itself.
2436
+ var pullPage = function () {
2437
+ var face = sessionFace();
2438
+ if (!face || typeof face.loadOlder !== "function") return false;
2439
+ if (historyBusy(face) || historyExhausted(face)) return false;
2440
+ try {
2441
+ var p = face.loadOlder();
2442
+ if (p && typeof p.catch === "function") p.catch(function () {});
2443
+ } catch (e) { return false; }
2444
+ return true;
2445
+ };
2446
+
2447
+ // ---- holding the reader's place while the list reflows ----------------------
2448
+ // Any load changes the list's height ABOVE the reader: the index reveals older
2449
+ // groups at the top, and a group that loads swaps two lines of preview for a whole
2450
+ // subtree. The list is a top-anchored flow, so a box can only grow downward — which
2451
+ // is exactly why the reader was pushed: everything under the grown box moves down.
2452
+ // The cure is not to grow the box upward (document flow cannot do that) but to take
2453
+ // that push back out of `scrollTop` in the SAME frame, which is indistinguishable
2454
+ // from growing upward: nothing the reader is looking at moves, the new rows simply
2455
+ // appear above the viewport. The anchor is the first group box in view, and
2456
+ // `data-group-idx` survives a ghost turning real (the group list does not change
2457
+ // length when a turn loads).
2458
+ // Scanning for it starts at the last known boundary: a full scan per scroll event
2459
+ // reads every rect in the list, which is what made long outlines stutter.
2460
+ var anchorAt = function (el) {
2461
+ var boxes = el.querySelectorAll("[data-group-idx]");
2462
+ if (!boxes || boxes.length === 0) return null;
2463
+ var lr = el.getBoundingClientRect();
2464
+ var limit = lr.top + 1;
2465
+ var i0 = anchorHintRef.current;
2466
+ if (i0 > boxes.length - 1) i0 = boxes.length - 1;
2467
+ if (i0 < 0) i0 = 0;
2468
+ // Walk from where the boundary was last seen — scrolling is gradual, so this reads
2469
+ // one or two rects per call. `i` ends up on the row that straddles the top edge.
2470
+ var i = i0;
2471
+ var r = boxes[i].getBoundingClientRect();
2472
+ while (r.bottom <= limit && i + 1 < boxes.length) { i++; r = boxes[i].getBoundingClientRect(); }
2473
+ while (r.bottom > limit && i > 0) {
2474
+ var prev = boxes[i - 1].getBoundingClientRect();
2475
+ if (prev.bottom <= limit) break;
2476
+ i--; r = prev;
2477
+ }
2478
+ // Choose the anchor. The FIRST REAL row in view wins: the placeholders ("未加载"
2479
+ // rows) are exactly what loads and grows, so anchoring below them keeps every row
2480
+ // the reader can see exactly where it is while the unloaded block grows upward out
2481
+ // of the viewport — which is the behaviour the reader asked for ("grow upward"). An
2482
+ // anchor ON a placeholder would sit still while everything under it slid down, and
2483
+ // an anchor on a row that straddles the top edge measures no growth at all (its own
2484
+ // top does not move). Falls back to the first fully visible row, then to whatever is
2485
+ // visible at the top. Bounded by what is on screen, so a few rects at most.
2486
+ var pick = i, pickTop = r.top, seenReal = false, seenFull = false;
2487
+ for (var k = i; k < boxes.length && k < i + 80; k++) {
2488
+ var rk = (k === i) ? r : boxes[k].getBoundingClientRect();
2489
+ if (rk.top >= lr.bottom) break; // past the bottom edge
2490
+ var ghostAttr = boxes[k].getAttribute ? boxes[k].getAttribute("data-ghost-turn") : null;
2491
+ var isGhost = ghostAttr !== null && ghostAttr !== undefined;
2492
+ if (!isGhost && !seenReal) { pick = k; pickTop = rk.top; seenReal = true; break; }
2493
+ if (isGhost && rk.top >= limit && !seenFull) { pick = k; pickTop = rk.top; seenFull = true; }
2494
+ }
2495
+ anchorHintRef.current = pick;
2496
+ return { idx: boxes[pick].getAttribute("data-group-idx"), top: pickTop };
2497
+ };
2498
+ // What the reader is looking at, and where it sits. Sampled by the scroll handler —
2499
+ // so a host-side change landing mid-scroll is still caught — and right before any
2500
+ // change we start ourselves.
2501
+ var sampleAnchor = function (el) {
2502
+ var a = el ? anchorAt(el) : null;
2503
+ anchorRef.current = a ? { idx: a.idx, top: a.top, scrollTop: el.scrollTop } : null;
2504
+ return anchorRef.current;
2505
+ };
2506
+ var restoreAnchor = function (el) {
2507
+ var a = anchorRef.current;
2508
+ if (!a || !el || !el.isConnected) return false;
2509
+ var node = el.querySelector('[data-group-idx="' + a.idx + '"]');
2510
+ if (!node) { sampleAnchor(el); return false; }
2511
+ var top = node.getBoundingClientRect().top;
2512
+ var moved = el.scrollTop - a.scrollTop; // what the reader (or a jump) scrolled
2513
+ // Growth above the anchor moves it DOWN by exactly as much as the content grew; the
2514
+ // reader scrolling up moves it down by exactly as much as they scrolled. Both happen
2515
+ // at once when a page lands mid-gesture, so subtract the reader's part rather than
2516
+ // refusing to compensate — the old "scrollTop changed, so the reader moved, skip"
2517
+ // guard is exactly why a load landing during a scroll was still seen pushing the
2518
+ // list. (The list carries `overflow-anchor: none`, so the browser is not adjusting
2519
+ // `scrollTop` behind our back and this sum stays honest.)
2520
+ var grew = (top - a.top) + moved;
2521
+ if (Math.abs(grew) < 0.5 || Math.abs(grew) > (el.clientHeight || 0) * 3) {
2522
+ // nothing grew — or something absurd happened (a repaged list, a session swap):
2523
+ // re-baseline instead of chasing it
2524
+ a.scrollTop = el.scrollTop; a.top = top;
2525
+ return false;
2526
+ }
2527
+ var before = el.scrollTop;
2528
+ el.scrollTop = before + grew;
2529
+ if (Math.abs(el.scrollTop - before) < 0.5) {
2530
+ // clamped (the reader is already at one end): nothing to hold on to
2531
+ sampleAnchor(el);
2532
+ return false;
2533
+ }
2534
+ a.scrollTop = el.scrollTop;
2535
+ a.top = top - grew; // the offset the anchor keeps from now on
2536
+ return true;
2537
+ };
2538
+ var holdPlace = function (el) { sampleAnchor(el); };
2539
+ // A host page lands over several ticks (window → snapshot → React render), so the
2540
+ // layout effect above is re-checked for a few frames — bounded, and it stops as soon
2541
+ // as the height settles, so an idle list costs nothing. Only ONE such loop may run:
2542
+ // a second one would correct the same content twice.
2543
+ var confirmPlace = function (el) {
2544
+ if (keepPlaceRafRef.current) cancelAnimationFrame(keepPlaceRafRef.current);
2545
+ var n = 0, lastH = -1;
2546
+ var step = function () {
2547
+ if (!el.isConnected || n > 8) { keepPlaceRafRef.current = null; return; }
2548
+ n++;
2549
+ restoreAnchor(el);
2550
+ var h = el.scrollHeight;
2551
+ if (h === lastH) { keepPlaceRafRef.current = null; return; }
2552
+ lastH = h;
2553
+ keepPlaceRafRef.current = requestAnimationFrame(step);
2554
+ };
2555
+ keepPlaceRafRef.current = requestAnimationFrame(step);
2556
+ };
2557
+ // ---- auto-load: follow the reader into the part that is not loaded ----------
2558
+ // The reader asked for this by name: "when I scroll into a gap, load it" — and,
2559
+ // just as firmly, "nothing at all when I turn it off". So the trigger is geometric
2560
+ // (an unloaded group that is actually in view) and the switch is consulted first.
2561
+ var AUTO_PACE_MS = 130; // beat while merely PREFETCHING (nothing to catch up to)
2562
+ var AUTO_WAIT_MS = 40; // beat while a page is in flight: the host is the limit
2563
+ var AUTO_BURST_MAX = 60; // runaway guard only: a long gap must stay walkable
2564
+ var AUTO_BURST_IDLE_MS = 3000; // quiet this long and the budget is fresh again
2565
+ var ghostInView = function (el) {
2566
+ var ghosts = el.querySelectorAll("[data-ghost-turn]");
2567
+ if (ghosts.length === 0) return null;
2568
+ var box = el.getBoundingClientRect();
2569
+ for (var i = 0; i < ghosts.length; i++) {
2570
+ var r = ghosts[i].getBoundingClientRect();
2571
+ if (r.bottom > box.top && r.top < box.bottom) return Number(ghosts[i].getAttribute("data-ghost-turn"));
2572
+ }
2573
+ return null;
2574
+ };
2575
+
2576
+ // How far AHEAD of the reader the gap is filled: while the bottom edge of the
2577
+ // nearest unloaded turn is still within this many viewports above the viewport's top
2578
+ // edge, the next page is fetched — so by the time the reader scrolls to where the
2579
+ // unloaded turns are, they are already real. The growth then happens ABOVE the view,
2580
+ // where nothing they are looking at can move; that is the trick that removes the seam
2581
+ // without animating anything.
2582
+ //
2583
+ // Measuring the GHOST (not the loaded/unloaded frontier) is what gives this its
2584
+ // hysteresis: the frontier group sits at the viewport's top edge by definition, so
2585
+ // "the frontier is within a viewport" was true no matter how much had just loaded,
2586
+ // and the loader pulled another page every beat until the whole log was in memory —
2587
+ // continuous host work, which is the stutter the reader hit while sitting still. A
2588
+ // ghost's distance, by contrast, grows by exactly the height of each page that lands,
2589
+ // so the chain stops once a page has put a viewport of real content above the reader.
2590
+ var PREFETCH_AHEAD = 1.2;
2591
+ var firstRealIndex = function () {
2592
+ var list = groupsRef.current || [];
2593
+ for (var i = 0; i < list.length; i++) if (!list[i].ghost) return i;
2594
+ return -1;
2595
+ };
2596
+ // The seq the host needs to page a turn in. The outline carries it for every turn
2597
+ // (loaded or not), because the host's projection and the jump loader both speak seq.
2598
+ var seqOfTurn = function (turn) {
2599
+ var list = groupsRef.current || [];
2600
+ for (var i = 0; i < list.length; i++) {
2601
+ if (list[i] && list[i].turn === turn) return typeof list[i].seq === "number" ? list[i].seq : null;
2602
+ }
2603
+ return null;
2604
+ };
2605
+ // Jump the window straight to a turn the reader has ARRIVED at. `loadThrough(seq)` lets
2606
+ // the host page its own history (200 messages a hop) until the window covers that seq,
2607
+ // so a deep gap closes in ONE call — the reader's complaint was exactly this ("I have
2608
+ // already scrolled into the unloaded part and it is still loading slowly"). Paging
2609
+ // 50 messages at a time stays the fallback, and stays the rule while merely
2610
+ // prefetching ahead of them, where there is nothing to catch up to.
2611
+ //
2612
+ // The target is a MARGIN further back than the row in view: jumping to exactly that one
2613
+ // turn left the next "未加载" row sitting immediately above it, so the reader saw the gap
2614
+ // again as soon as they kept scrolling. Covering `AUTO_JUMP_MARGIN` rows at a time makes
2615
+ // one jump serve the next stretch of scrolling too.
2616
+ var AUTO_JUMP_MARGIN = 8;
2617
+ var jumpSeqFor = function (turn) {
2618
+ var list = groupsRef.current || [];
2619
+ var at = -1;
2620
+ for (var i = 0; i < list.length; i++) if (list[i] && list[i].turn === turn) { at = i; break; }
2621
+ if (at < 0) return seqOfTurn(turn);
2622
+ for (var k = Math.max(0, at - AUTO_JUMP_MARGIN); k <= at; k++) {
2623
+ if (list[k] && typeof list[k].seq === "number") return list[k].seq;
2624
+ }
2625
+ return seqOfTurn(turn);
2626
+ };
2627
+ var loadThroughTurn = function (turn) {
2628
+ var face = sessionFace();
2629
+ if (!face || typeof face.loadThrough !== "function") return false;
2630
+ var seq = jumpSeqFor(turn);
2631
+ if (seq === null) return false;
2632
+ if (historyBusy(face) || historyExhausted(face)) return false;
2633
+ try {
2634
+ var p = face.loadThrough(seq);
2635
+ if (p && typeof p.catch === "function") p.catch(function () {});
2636
+ } catch (e) { return false; }
2637
+ return true;
2638
+ };
2639
+ var frontierApproaching = function (el) {
2640
+ // Read the UNLOADED rows by their own attribute. (They also carry
2641
+ // `data-group-idx`, but asking for the placeholder directly says what this is
2642
+ // about — and it is fewer boxes to measure.)
2643
+ var ghosts = el.querySelectorAll ? el.querySelectorAll("[data-ghost-turn]") : [];
2644
+ var box = el.getBoundingClientRect();
2645
+ var ahead = (box.height || 0) * PREFETCH_AHEAD;
2646
+ var above = null; // distance from the nearest unloaded turn up to the viewport
2647
+ for (var i = 0; i < ghosts.length; i++) {
2648
+ var r = ghosts[i].getBoundingClientRect();
2649
+ if (r.bottom > box.top) return true; // unloaded content is in view
2650
+ above = box.top - r.bottom; // the last ghost is the nearest one
2651
+ }
2652
+ return above !== null && above < ahead;
2653
+ };
2654
+ var autoRetry = function (fast) {
2655
+ if (autoTimerRef.current) return;
2656
+ autoTimerRef.current = setTimeout(function () {
2657
+ autoTimerRef.current = null;
2658
+ pullBusyRef.current = false;
2659
+ var el = listRef.current;
2660
+ if (!el) return;
2661
+ // If our own pull changed nothing while the host still has older history, this
2662
+ // build is not honouring the API pull: fall back to the host's own control, once
2663
+ // per attempt. The reader asked for exactly this ("click that for me").
2664
+ var w = autoWatchRef.current;
2665
+ if (w) {
2666
+ var moved = firstRealIndex() !== w.frontier;
2667
+ if (moved) autoWatchRef.current = null;
2668
+ else {
2669
+ w.tries = (w.tries || 0) + 1;
2670
+ // three beats (≈0.4s) with nothing moving means this build is not landing our
2671
+ // pull: click the host's own control once instead of waiting forever
2672
+ if (!w.clicked && w.tries >= 3) {
2673
+ var face2 = sessionFace();
2674
+ if (!face2 || !historyBusy(face2)) w.clicked = viaHostButton() || true;
2675
+ }
2676
+ }
2677
+ }
2678
+ autoLoadStep(el);
2679
+ }, fast ? AUTO_WAIT_MS : AUTO_PACE_MS);
2680
+ };
2681
+ var autoLoadStep = function (el) {
2682
+ if (!autoLoadRef.current || !el || !el.isConnected) return false;
2683
+ if (pullBusyRef.current) return true; // a pull is in flight: still progressing
2684
+ var now = Date.now();
2685
+ if (now - autoStampRef.current > AUTO_BURST_IDLE_MS) autoBurstRef.current = 0;
2686
+ if (autoBurstRef.current >= AUTO_BURST_MAX) return false;
2687
+ var face = sessionFace();
2688
+ var ghostTurn = ghostInView(el);
2689
+ if (!face) {
2690
+ // no session service (older host): the host's own control is all there is
2691
+ if (el.scrollTop <= 24 && findLoadOlderButton() && viaHostButton()) {
2692
+ autoBurstRef.current++; autoStampRef.current = now; return true;
2693
+ }
2694
+ return false;
2695
+ }
2696
+ if (historyBusy(face)) {
2697
+ // a page is in flight: WAIT for it, on a SHORT beat, so the next one starts as soon
2698
+ // as the host is done with this one (the host, not our pacing, should be the limit).
2699
+ // Reading "in flight" as "no more history" is the bug that stopped the loading.
2700
+ // These waits are not pages, so they do not spend the page budget — but they are
2701
+ // capped, so a genuinely stuck pull cannot spin here.
2702
+ autoBusyRef.current++;
2703
+ if (autoBusyRef.current <= 60) autoRetry(true);
2704
+ return true;
2705
+ }
2706
+ if (historyExhausted(face)) return false;
2707
+ // A ghost the reader can actually see, or the gap coming up on them.
2708
+ if (ghostTurn !== null || frontierApproaching(el)) {
2709
+ // ARRIVED at an unloaded turn: cover it in one host call (`loadThrough`), which is
2710
+ // what the reader expects ("it should load the moment I scroll to it"). A deep gap
2711
+ // walked 50 messages at a time is what made it feel like it never caught up.
2712
+ // MERELY APPROACHING (the prefetch band): one page at a time, so history is fetched
2713
+ // just ahead of the reader and nothing large lands while they sit still.
2714
+ holdPlace(el); // remember the place BEFORE the height changes
2715
+ autoBurstRef.current++; autoStampRef.current = now;
2716
+ pullBusyRef.current = true;
2717
+ var started = ghostTurn !== null
2718
+ ? (loadThroughTurn(ghostTurn) || pullPage())
2719
+ : pullPage();
2720
+ if (!started) { pullBusyRef.current = false; return false; }
2721
+ autoBusyRef.current = 0; // a load started: the wait cap is per stuck load
2722
+ // One watch across the whole fruitless streak, not one per pull: re-arming it
2723
+ // every beat would reset the count and the fallback would never fire.
2724
+ if (!autoWatchRef.current) autoWatchRef.current = { frontier: firstRealIndex(), tries: 0, clicked: false };
2725
+ confirmPlace(el); // a load lands over several ticks
2726
+ autoRetry(true);
2727
+ return true;
2728
+ }
2729
+ // Nothing unloaded is near, so there is nothing to fetch. There used to be an
2730
+ // unconditional "we are at the very top, pull anyway" branch here; it was both
2731
+ // redundant (at the true top the unloaded turns ARE on screen, so the branch above
2732
+ // fires) and harmful: it kept pulling while the reader sat still, page after page,
2733
+ // which is host work they never asked for.
2734
+ return false;
2735
+ };
2250
2736
  var loadOlderOutline = function (el) {
2251
2737
  var grow = function () {
2252
2738
  if (visibleCount < groups.length) {
2253
- var prevH = el.scrollHeight;
2739
+ // Revealing older rows prepends them, so the reader's content is pushed down.
2740
+ // Sample first and let the layout effect take the push out again in the same
2741
+ // frame: correcting two frames later (the old double-rAF) IS the bounce the
2742
+ // reader saw when they reached the unloaded part of the list.
2743
+ holdPlace(el);
2254
2744
  setVisibleCount(Math.min(groups.length, visibleCount + PAGE_SIZE));
2255
- requestAnimationFrame(function () {
2256
- requestAnimationFrame(function () {
2257
- el.scrollTop += (el.scrollHeight - prevH);
2258
- });
2259
- });
2745
+ // the revealed rows are not in the DOM yet, so re-check once after the render:
2746
+ // if the reveal uncovered unloaded turns, they load within the same gesture
2747
+ autoRetry();
2260
2748
  return true;
2261
2749
  }
2262
2750
  return false;
2263
2751
  };
2264
- var liftOffBottom = function () {
2265
- // DSH re-pins the transcript to the newest message whenever it sees a
2266
- // scroll it does not attribute to the reader while it still believes the
2267
- // reader is at the bottom (`toBottom`). Paging history in from the OUTLINE
2268
- // is exactly that case: the prepend compensation DSH applies is a
2269
- // programmatic scroll, `atBottomRef` was never cleared (the reader never
2270
- // touched the transcript), so the page-in is immediately undone and the
2271
- // whole view — and with it the outline's follow — snaps back to the newest
2272
- // turn. Nudging the transcript just past DSH's 25px stick zone first makes
2273
- // that scroll read as a reader movement, so the page-in keeps its place.
2274
- // (Same trick `glideTo` uses so a jump is not yanked back.)
2275
- var sp = document.querySelector("[data-conversation-scroll]");
2276
- if (!sp) return;
2277
- var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
2278
- if (floor - sp.scrollTop <= 25) sp.scrollTop = Math.max(0, floor - 26);
2279
- };
2280
- var viaHost = function () {
2281
- var btn = findLoadOlderButton();
2282
- if (!btn || btn.disabled) return false;
2283
- liftOffBottom();
2284
- btn.click();
2285
- // once the conversation loads more, expand the outline window too
2286
- setTimeout(function () {
2287
- var prevH2 = el.scrollHeight;
2288
- if (grow()) {
2289
- requestAnimationFrame(function () {
2290
- requestAnimationFrame(function () {
2291
- el.scrollTop += (el.scrollHeight - prevH2);
2292
- });
2293
- });
2294
- }
2295
- }, 1200);
2296
- return true;
2297
- };
2298
- var viaHostThrottled = function () {
2299
- var now = Date.now();
2300
- if (now - lastHostClickRef.current < 900) return false;
2301
- if (!findLoadOlderButton()) return false;
2302
- lastHostClickRef.current = now;
2303
- return viaHost();
2304
- };
2305
- // In SEARCH mode the list shows matches, not the outline: paging the
2306
- // outline's own window would change nothing the reader can see, whereas
2307
- // loading the conversation's older messages extends what the search can
2308
- // cover at all (the whole-log index only carries bounded previews). So the
2309
- // host's own "load earlier" comes first while a query is active.
2310
- if (query.trim() !== "") return viaHost() || grow();
2311
- // Outline mode keeps the ORIGINAL contract: scrolling the outline up also
2312
- // pulls the conversation's older messages in (that is what makes the outline
2313
- // a substitute for scrolling the transcript), not merely more index entries.
2314
- // The host click is throttled so one continuous scroll cannot hammer the
2315
- // pager; grow() still runs first so the index never lags behind the window.
2752
+ var viaHostButton = function () { return viaHostButtonRef.current(); };
2753
+ // In SEARCH mode the list shows matches, not the outline: paging the outline's
2754
+ // own window would change nothing the reader can see, whereas loading the
2755
+ // conversation's older messages extends what the search can cover at all (the
2756
+ // whole-log index only carries bounded previews). So the host pull comes first
2757
+ // while a query is active.
2758
+ if (query.trim() !== "") {
2759
+ holdPlace(el);
2760
+ var pulled = pullPage() || viaHostButton();
2761
+ if (pulled) confirmPlace(el);
2762
+ return pulled || grow();
2763
+ }
2764
+ // Outline mode: reveal more of the index, and let the auto-loader fetch the
2765
+ // history behind the gap the reader has scrolled into (see autoLoadStep).
2316
2766
  var grew = grow();
2317
- return viaHostThrottled() || grew;
2767
+ var fetched = autoLoadStep(el);
2768
+ return grew || fetched;
2318
2769
  };
2319
2770
 
2320
2771
  // older history still reachable? the outline can always page its own window
2321
- // further, and beyond that the conversation's own "load older" button decides
2772
+ // further, and beyond that the session face decides (see historyExhausted)
2322
2773
  var canLoadOlder = function () {
2323
- return visibleCount < groups.length || !!findLoadOlderButton();
2774
+ var face = sessionFace();
2775
+ if (visibleCount < groups.length) return true;
2776
+ if (face) return !historyExhausted(face);
2777
+ return !!findLoadOlderButton();
2778
+ };
2779
+ // Positive evidence that the beginning really is reached — the ONLY state that may
2780
+ // claim it in the hint. A pull in flight, a spent burst budget or a switched-off
2781
+ // auto-loader are all "not now", never "nothing older".
2782
+ var atHistoryStart = function () {
2783
+ if (visibleCount < groups.length) return false;
2784
+ var face = sessionFace();
2785
+ if (face) return historyExhausted(face);
2786
+ return !findLoadOlderButton();
2324
2787
  };
2325
2788
 
2326
2789
  // wheel up (toward older) loads more when the list is at its top or has
@@ -2344,9 +2807,15 @@ window.__ModuleLoader__.load({
2344
2807
  if (!progressed) {
2345
2808
  if (query.trim() !== "") {
2346
2809
  if (hint !== "oldest") setHint("oldest");
2347
- } else {
2810
+ } else if (atHistoryStart()) {
2348
2811
  showBanner(T("search.hintOldest"));
2812
+ } else if (!prefs.autoLoad) {
2813
+ // the honest message when the reader switched auto-loading off: there IS
2814
+ // more history, it simply is not being fetched behind their back
2815
+ showBanner(T("hint.autoOff"));
2349
2816
  }
2817
+ // otherwise a page is in flight (or this gesture's budget is spent): the
2818
+ // silence is deliberate — "已经是最早" here is what used to lie
2350
2819
  }
2351
2820
  }
2352
2821
  };
@@ -2354,6 +2823,8 @@ window.__ModuleLoader__.load({
2354
2823
  // scroll to the top edge also loads older groups (keeps the visual position)
2355
2824
  var onListScroll = function (e) {
2356
2825
  outlineTouchRef.current = Date.now();
2826
+ // the scroll container itself: a real scroll event's `currentTarget` IS the list
2827
+ // element, which is also what the paging logic reads geometry from
2357
2828
  var el = e.currentTarget;
2358
2829
  syncAtBottom(el);
2359
2830
  var upward = el.scrollTop < lastScrollTopRef.current - 2;
@@ -2362,6 +2833,20 @@ window.__ModuleLoader__.load({
2362
2833
  if (hoverCard) hoverEnd();
2363
2834
  }
2364
2835
  lastScrollTopRef.current = el.scrollTop;
2836
+ syncBackBtn(el); // reached the remembered place by hand? the button is done
2837
+ // Remember what the reader is looking at. Sampling on every scroll event is what
2838
+ // lets a change the reader did NOT start — the host extending the window by itself
2839
+ // — be compensated too: the layout effect knows where the anchored row was before
2840
+ // the height changed and puts it back before the push is ever painted.
2841
+ sampleAnchor(el);
2842
+ // The gap is filled AHEAD of the reader, so "reached the very top" is not the only
2843
+ // trigger any more: on any upward movement, ask the auto-loader whether the
2844
+ // unloaded stretch is coming into range (it decides, see frontierApproaching).
2845
+ // Throttled, because that probe reads geometry.
2846
+ if (upward && el.scrollTop > 24 && Date.now() - autoProbeRef.current > 120) {
2847
+ autoProbeRef.current = Date.now();
2848
+ autoLoadStep(el);
2849
+ }
2365
2850
  if (el.scrollTop <= 24) {
2366
2851
  // Reaching the top is the ONLY place the exhausted case can be told
2367
2852
  // apart from "keep scrolling": after a search the list is parked at the
@@ -2370,8 +2855,10 @@ window.__ModuleLoader__.load({
2370
2855
  if (!progressed && upward) {
2371
2856
  if (query.trim() !== "") {
2372
2857
  if (hint !== "oldest") setHint("oldest");
2373
- } else {
2858
+ } else if (atHistoryStart()) {
2374
2859
  showBanner(T("search.hintOldest"));
2860
+ } else if (!prefs.autoLoad) {
2861
+ showBanner(T("hint.autoOff"));
2375
2862
  }
2376
2863
  }
2377
2864
  }
@@ -2610,6 +3097,11 @@ window.__ModuleLoader__.load({
2610
3097
  // render (the "did that turn actually page in?" verdict below).
2611
3098
  var groupsLiveRef = react.useRef(groups);
2612
3099
  groupsLiveRef.current = groups;
3100
+ react.useEffect(function () {
3101
+ return function () {
3102
+ if (keepPlaceRafRef.current) cancelAnimationFrame(keepPlaceRafRef.current);
3103
+ };
3104
+ }, []);
2613
3105
  // One history-rebuild retry per session after a jump that never landed.
2614
3106
  // Hooks must sit ABOVE every early return (this panel returns null for an
2615
3107
  // empty conversation) or React aborts with #310.
@@ -2816,6 +3308,25 @@ window.__ModuleLoader__.load({
2816
3308
  var followFrame = null;
2817
3309
  var followTimer = null;
2818
3310
  var lastRun = 0;
3311
+ // A long session's conversation holds thousands of message anchors, and reading a
3312
+ // rect for every one of them ~16x/s (plus the 250ms poll) is main-thread work that
3313
+ // GROWS with the session length — that is frame loss the reader notices after
3314
+ // leaving the app open for a long time. So the node list is cached and the scan
3315
+ // starts at the boundary seen last time: a handful of rects per update instead of
3316
+ // thousands. The cache is dropped when the port changes or its two ends change
3317
+ // (the host mounts and unmounts messages while it repages the window).
3318
+ var cachedSp = null, cachedRows = null, cachedCount = -1, cachedFirst = null, cachedLast = null;
3319
+ var cursor = 0;
3320
+ var rowsOf = function (sp) {
3321
+ var list = sp.querySelectorAll("[data-chat-anchor-key]");
3322
+ var first = list.length > 0 ? list[0].dataset.chatAnchorKey : null;
3323
+ var last = list.length > 0 ? list[list.length - 1].dataset.chatAnchorKey : null;
3324
+ if (sp !== cachedSp || list.length !== cachedCount || first !== cachedFirst || last !== cachedLast) {
3325
+ cachedSp = sp; cachedRows = list; cachedCount = list.length;
3326
+ cachedFirst = first; cachedLast = last; cursor = 0;
3327
+ }
3328
+ return cachedRows;
3329
+ };
2819
3330
  var update = function (force) {
2820
3331
  // a jump glide carries the conversation past many turns; following each
2821
3332
  // transient position would make the rail sprint through the whole
@@ -2824,9 +3335,13 @@ window.__ModuleLoader__.load({
2824
3335
  if (glide) return;
2825
3336
  // scroll frames are hot and a document-level capture listener sees every
2826
3337
  // scroll in the app; the rail only changes when a different turn crosses
2827
- // the viewport, so cap the row measurement at ~16/s
3338
+ // the viewport, so cap the row measurement. While the panel is COLLAPSED
3339
+ // (or handed to the curtain) nothing on screen can change, so the cap goes
3340
+ // way up — but the scan still runs, because it is also what remembers the
3341
+ // reader's position.
2828
3342
  var now = Date.now();
2829
- if (!force && now - lastRun < 60) return;
3343
+ var gap = panelVisibleRef.current ? 60 : 800;
3344
+ if (!force && now - lastRun < gap) return;
2830
3345
  lastRun = now;
2831
3346
  if (!chatViewRef.current) return;
2832
3347
  var sp = document.querySelector("[data-conversation-scroll]");
@@ -2834,16 +3349,25 @@ window.__ModuleLoader__.load({
2834
3349
  var lr = sp.getBoundingClientRect();
2835
3350
  var vTop = lr.top;
2836
3351
  var vBottom = lr.top + lr.height;
2837
- var rows = sp.querySelectorAll("[data-chat-anchor-key]");
3352
+ var rows = rowsOf(sp);
3353
+ if (!rows || rows.length === 0) return;
3354
+ if (cursor > rows.length - 1) cursor = rows.length - 1;
3355
+ if (cursor < 0) cursor = 0;
3356
+ var cr = rows[cursor].getBoundingClientRect();
3357
+ while (cr.bottom <= vTop && cursor + 1 < rows.length) { cursor++; cr = rows[cursor].getBoundingClientRect(); }
3358
+ while (cr.bottom > vTop && cursor > 0) {
3359
+ var pr = rows[cursor - 1].getBoundingClientRect();
3360
+ if (pr.bottom <= vTop) break;
3361
+ cursor--; cr = pr;
3362
+ }
2838
3363
  var actives = [];
2839
3364
  var seen = {};
2840
- for (var i = 0; i < rows.length; i++) {
3365
+ for (var i = cursor; i < rows.length; i++) {
2841
3366
  var r = rows[i].getBoundingClientRect();
2842
- if (r.top < vBottom && r.bottom > vTop) {
2843
- var k = rows[i].dataset.chatAnchorKey;
2844
- var gi = keyToGroup[k];
2845
- if (gi !== undefined && !seen[gi]) { seen[gi] = true; actives.push(gi); }
2846
- }
3367
+ if (r.top >= vBottom) break; // document order: nothing below is visible either
3368
+ var k = rows[i].dataset.chatAnchorKey;
3369
+ var gi = keyToGroup[k];
3370
+ if (gi !== undefined && !seen[gi]) { seen[gi] = true; actives.push(gi); }
2847
3371
  }
2848
3372
  var sig = actives.slice().sort().join(",");
2849
3373
  if (sig === activeSigRef.current) return;
@@ -2869,11 +3393,12 @@ window.__ModuleLoader__.load({
2869
3393
  if (!el) return;
2870
3394
  var node = el.querySelector('[data-group-idx="' + gi0 + '"]');
2871
3395
  if (!node) return;
2872
- var er = node.getBoundingClientRect();
2873
- var lr2 = el.getBoundingClientRect();
2874
- if (er.top < lr2.top - 2 || er.bottom > lr2.bottom + 2) {
2875
- el.scrollTop = el.scrollTop + (er.top - lr2.top) - el.clientHeight / 2 + node.offsetHeight / 2;
2876
- }
3396
+ // The turn being read goes to the TOP of the list. This used to CENTRE it, which is
3397
+ // why the follow stopped "a little short of the bottom": for the newest turn the
3398
+ // centre is above the end of the list, so the scroll landed half a viewport early.
3399
+ // Aligning to the top needs no clamp at all the list's own end does the work.
3400
+ var delta = node.getBoundingClientRect().top - el.getBoundingClientRect().top;
3401
+ if (Math.abs(delta) > 1) el.scrollTop = el.scrollTop + delta;
2877
3402
  };
2878
3403
  followFrame = requestAnimationFrame(function () { followFrame = requestAnimationFrame(place); });
2879
3404
  followTimer = setTimeout(place, 90); // the row may only exist after the expansion commits
@@ -3187,7 +3712,14 @@ window.__ModuleLoader__.load({
3187
3712
  var posTimerRef = react.useRef(null);
3188
3713
  var rememberRef = react.useRef(true);
3189
3714
  rememberRef.current = !!prefs.remember;
3715
+ // the auto-loader's switch, mirrored for the same reason: its retry fires from an
3716
+ // older render's closure, and turning the setting off must stop it immediately
3717
+ autoLoadRef.current = !!prefs.autoLoad;
3718
+ // Is any of the outline actually on screen? The follow scan is cheap now, but while
3719
+ // the panel sits collapsed there is no point measuring at scroll-frame rate.
3720
+ panelVisibleRef.current = !!(open || sheetOpen);
3190
3721
  react.useEffect(function () { return function () { if (posTimerRef.current) clearTimeout(posTimerRef.current); }; }, []);
3722
+ react.useEffect(function () { return function () { if (autoTimerRef.current) clearTimeout(autoTimerRef.current); }; }, []);
3191
3723
  // `g` is the outline group the reader is looking at (its turn AND its start seq,
3192
3724
  // so the position can be paged back in later, not just jumped to while loaded)
3193
3725
  var savePosition = function (g) {
@@ -3290,16 +3822,73 @@ window.__ModuleLoader__.load({
3290
3822
  openTurn(saved.turn, seq === null ? undefined : seq, true);
3291
3823
  }, [groups, prefs.remember, chatViewActive, sessionId]);
3292
3824
 
3293
- // ---- opening the sheet: land on the turn being read ----------------------
3825
+ // ---- holding the reader's place, before the browser paints ----------------
3826
+ // Every render can have changed the list's height: older rows revealed, a ghost
3827
+ // replaced by a whole real subtree, the host extending its window on its own. This
3828
+ // runs after React wrote the DOM and BEFORE the browser paints, which is the only
3829
+ // place the correction is invisible — the reader sees the new rows appear above the
3830
+ // viewport instead of seeing their own content pushed down and then snapped back.
3831
+ // It writes nothing when the anchor has not moved, so an idle list costs one
3832
+ // measurement per render and nothing else.
3833
+ // No dep array on purpose: the height can change for reasons this component does not
3834
+ // get told about (the host repaging its window changes the rows' heights without
3835
+ // necessarily changing the group list's identity), and the check is cheap — one
3836
+ // attribute query and one rect, and it writes nothing when the anchor has not moved.
3837
+ react.useLayoutEffect(function () {
3838
+ var el = listRef.current;
3839
+ if (el) restoreAnchor(el);
3840
+ });
3841
+
3842
+ // ---- opening a surface: land on the turn being read ----------------------
3843
+ // ONE rule for both surfaces: what the reader is reading sits at the TOP of the list —
3844
+ // except when it is one of the newest turns, where the list cannot scroll any further and
3845
+ // the row therefore lands at the bottom. Nothing clamps it: the rule limits itself.
3846
+ // The docked panel used to just show wherever the list had last been left, which is
3847
+ // exactly why the two surfaces disagreed about where they open.
3848
+ //
3294
3849
  // Hooks stay above the early return; the helpers they call are assigned later
3295
3850
  // in the render body, which is fine because this runs after the render.
3296
- react.useEffect(function () {
3297
- if (!sheetOpen) return;
3851
+ var landOnReadingRow = function () {
3298
3852
  var el = listRef.current;
3299
- if (el && typeof el.focus === "function") el.focus();
3853
+ if (!el) return undefined;
3854
+ // A reader who had not scrolled the outline is at its bottom, and opening the CURTAIN
3855
+ // must not move them: its container geometry changes wholesale, and the "hold the
3856
+ // anchor" correction would put a stale row back where it was — the list then sat a
3857
+ // little above the newest turn until the follow walked it down. Pin the bottom instead,
3858
+ // for this transition only, and skip the reveal (there is nothing to reveal to: they
3859
+ // are already reading the newest turn). The docked panel reshapes nothing — it opens
3860
+ // around a list that kept its geometry — so there it has nothing to pin.
3861
+ if (atBottomRef.current) {
3862
+ if (!sheetOpen) return undefined;
3863
+ anchorRef.current = null; // no compensation against a stale anchor
3864
+ el.scrollTop = el.scrollHeight;
3865
+ syncAtBottom(el);
3866
+ var again = requestAnimationFrame(function () { // ...and once more, after layout
3867
+ var el2 = listRef.current;
3868
+ if (!el2 || !atBottomRef.current) return;
3869
+ el2.scrollTop = el2.scrollHeight;
3870
+ syncAtBottom(el2);
3871
+ sampleAnchor(el2);
3872
+ });
3873
+ return function () { cancelAnimationFrame(again); };
3874
+ }
3300
3875
  var gi = activeGroup && activeGroup.length > 0 ? activeGroup[0] : -1;
3301
3876
  if (gi >= 0) revealGroupInOutline(gi);
3877
+ return undefined;
3878
+ };
3879
+ react.useEffect(function () {
3880
+ if (!sheetOpen) return undefined;
3881
+ var el = listRef.current;
3882
+ if (el && typeof el.focus === "function") el.focus();
3883
+ return landOnReadingRow();
3302
3884
  }, [sheetOpen]);
3885
+ // The docked panel obeys the same rule when the reader opens IT. Only its own open counts
3886
+ // (`open` false -> true): the curtain handing the panel back leaves `open` true, and there
3887
+ // the list keeps the place the reader was just looking at in the curtain.
3888
+ react.useEffect(function () {
3889
+ if (!open) return undefined;
3890
+ return landOnReadingRow();
3891
+ }, [open]);
3303
3892
 
3304
3893
  // ---- nothing to show without headings ----
3305
3894
  if (groups.length === 0) return null;
@@ -3323,22 +3912,25 @@ window.__ModuleLoader__.load({
3323
3912
  // rendered at its COLLAPSED geometry, so the same left/clip transition that
3324
3913
  // serves open/collapse also serves the handoff: no teleport, no flash.
3325
3914
  var geoOpen = open && !panelAside;
3326
- var panelLeft;
3915
+ // The box is always PARKED at its collapsed spot and the open state is a transform
3916
+ // offset on top of it (see panelTransition): same visual geometry as before, but the
3917
+ // slide is compositable. `openLeft` is the clamped open position, `parkedLeft` the
3918
+ // off-area spot the collapsed panel occupies.
3919
+ var openLeft, parkedLeft;
3327
3920
  if (dockRight) {
3328
3921
  // RIGHT dock: keep the panel's VISIBLE right edge pinned at the SAME
3329
3922
  // line when collapsed as when expanded (the expand edge is correct).
3330
- // open: left = openRight, visible right edge = openRight+panelW
3331
- // closed: left = openRight + panelW, clip-right panelW hides it fully,
3332
- // so the visible right edge stays at openRight+panelW -> the
3333
- // collapse shrinks toward that line and vanishes exactly there.
3334
3923
  var openRight = window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW;
3335
- panelLeft = geoOpen ? clampLeft(openRight) : (openRight + panelW);
3336
- } else if (geoOpen) {
3337
- // LEFT dock open (left dock is the confirmed-correct reference — untouched)
3338
- panelLeft = clampLeft(viewport ? viewport.left + 8 : 8);
3924
+ openLeft = clampLeft(openRight);
3925
+ parkedLeft = openRight + panelW;
3339
3926
  } else {
3340
- panelLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
3927
+ // LEFT dock open (left dock is the confirmed-correct reference untouched)
3928
+ openLeft = clampLeft(viewport ? viewport.left + 8 : 8);
3929
+ parkedLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
3341
3930
  }
3931
+ var panelLeft = parkedLeft;
3932
+ var panelOffset = geoOpen ? (openLeft - parkedLeft) : 0;
3933
+ var panelTransform = panelOffset === 0 ? "none" : "translateX(" + zLen(panelOffset) + ")";
3342
3934
  var vMaxH = viewport ? Math.max(200, viewport.height - 28) : "72vh";
3343
3935
  // same reasoning vertically: the draggable offset is stored in localStorage
3344
3936
  // and can outlive the layout it was chosen for
@@ -3762,16 +4354,34 @@ window.__ModuleLoader__.load({
3762
4354
 
3763
4355
  // reveal one group inside the outline list (shared by Enter-stepping and by
3764
4356
  // jumps into a turn that had to be loaded first)
4357
+ // Put a group's row at the TOP of the list, instantly. The previous way —
4358
+ // `scrollIntoView({ behavior: "smooth", block: "nearest" })` — only scrolled the minimum
4359
+ // distance (so the row could end up anywhere in the viewport, which reads as "the jump is
4360
+ // not accurate") and it ANIMATED there, which is the scroll the reader saw when opening
4361
+ // the panel or the curtain.
4362
+ //
4363
+ // Aligning to the top is also self-limiting, which is why it needs no special case: on the
4364
+ // newest turn the list simply cannot scroll further, so `scrollTop` clamps at the end and
4365
+ // that row lands exactly at the bottom.
4366
+ var alignGroupToTop = function (gi) {
4367
+ var el = listRef.current;
4368
+ if (!el) return false;
4369
+ var node = el.querySelector('[data-group-idx="' + gi + '"]');
4370
+ if (!node) return false;
4371
+ var delta = node.getBoundingClientRect().top - el.getBoundingClientRect().top;
4372
+ if (Math.abs(delta) > 0.5) el.scrollTop = el.scrollTop + delta;
4373
+ return true;
4374
+ };
3765
4375
  var revealGroupInOutline = function (gi) {
3766
4376
  setVisibleCount(function (prev) {
3767
4377
  return Math.max(prev, Math.min(groups.length, groups.length - gi));
3768
4378
  });
3769
- setTimeout(function () {
3770
- var el = listRef.current;
3771
- if (!el) return;
3772
- var node = el.querySelector('[data-group-idx="' + gi + '"]');
3773
- if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
3774
- }, 150);
4379
+ // the row may only exist once the commit has happened, so retry across the next frames
4380
+ alignGroupToTop(gi);
4381
+ requestAnimationFrame(function () {
4382
+ alignGroupToTop(gi);
4383
+ requestAnimationFrame(function () { alignGroupToTop(gi); });
4384
+ });
3775
4385
  };
3776
4386
 
3777
4387
  // jump to the n-th match, cycling; also reveal the group in the outline.
@@ -3996,6 +4606,10 @@ window.__ModuleLoader__.load({
3996
4606
  if (!open) return;
3997
4607
  swallow(e);
3998
4608
  setNavIdx(-1);
4609
+ // Esc is a way of LEAVING the docked panel, so it records the same thing the ✕ does.
4610
+ // Without this the memory kept whatever the last ✕ close had stored, and reopening
4611
+ // would offer to go back to a row the reader had left two closes ago.
4612
+ noteClosed("dock");
3999
4613
  setOpen(false);
4000
4614
  }
4001
4615
  };
@@ -4050,7 +4664,14 @@ window.__ModuleLoader__.load({
4050
4664
  // travelling when the curtain took over, which looked like it flashed away.
4051
4665
  var panelAway = !open || panelAside === "tuck";
4052
4666
  var slide = panelAway ? "0.28s " + EASE : "0.6s " + EASE;
4053
- var panelTransition = (zoomJustChanged ? "" : "left " + slide + ", ")
4667
+ // The slide is a TRANSFORM, not `left`. Animating `left` re-lays out the whole panel —
4668
+ // header, toolbar and every rendered outline row — on every frame of the animation, and
4669
+ // once a long session has loaded thousands of rows that layout can no longer keep up
4670
+ // with the (compositable) `clip-path` wipe next to it: the wipe ran on while the slide
4671
+ // stalled, which is the "a line sweeps across and the panel never really slides" the
4672
+ // reader reported. A transform is handed to the compositor, so the animation no longer
4673
+ // depends on how big the list is.
4674
+ var panelTransition = (zoomJustChanged ? "" : "transform " + slide + ", ")
4054
4675
  + "clip-path " + slide + ", opacity " + (panelAway ? "0.14s ease 0.26s" : "0.45s ease");
4055
4676
 
4056
4677
  // clip the panel at the dock edge while collapsed, so sliding away looks
@@ -4142,7 +4763,7 @@ window.__ModuleLoader__.load({
4142
4763
  viewBox: "0 0 14 14",
4143
4764
  fill: "none",
4144
4765
  stroke: "currentColor",
4145
- strokeWidth: 1.8,
4766
+ strokeWidth: 2.2,
4146
4767
  strokeLinecap: "round",
4147
4768
  style: { display: "block" },
4148
4769
  children: [
@@ -4163,13 +4784,18 @@ window.__ModuleLoader__.load({
4163
4784
  viewBox: "0 0 14 14",
4164
4785
  fill: "none",
4165
4786
  stroke: "currentColor",
4166
- strokeWidth: 1.8,
4787
+ strokeWidth: 2.2,
4167
4788
  strokeLinecap: "round",
4168
4789
  strokeLinejoin: "round",
4169
4790
  style: { display: "block" },
4170
4791
  children: [
4171
- react_jsx_runtime.jsx("rect", { x: 1.6, y: 2.4, width: 10.8, height: 7.2, rx: 1.6 }),
4172
- react_jsx_runtime.jsx("path", { d: "M4.6 9.6v2.6l2.8-2.6" })
4792
+ // A slightly LARGER bubble (11.4 x 7.7), and the whole glyph sits a touch BELOW the
4793
+ // box centre on purpose: the bubble body is the visual mass while the tail hangs
4794
+ // off the bottom-left, so a geometrically centred bubble reads as top-heavy. The
4795
+ // stroke bbox is 1.4..13.8 (centre 7.6, i.e. 0.6 low) and the horizontal centre is
4796
+ // still exactly 7.
4797
+ react_jsx_runtime.jsx("rect", { x: 1.3, y: 2.5, width: 11.4, height: 7.7, rx: 1.7 }),
4798
+ react_jsx_runtime.jsx("path", { d: "M4.5 10.2v2.5l2.9-2.5" })
4173
4799
  ]
4174
4800
  }),
4175
4801
  { "data-questions": questionsOnly ? "on" : "off" }
@@ -4184,14 +4810,16 @@ window.__ModuleLoader__.load({
4184
4810
  viewBox: "0 0 24 24",
4185
4811
  // overflow visible: the stroke may extend past the
4186
4812
  // viewBox without being clipped at the svg boundary
4187
- style: { display: "block", transform: "translate(-1px, -1px)", overflow: "visible" },
4813
+ style: { display: "block", overflow: "visible" },
4188
4814
  fill: "none",
4189
4815
  stroke: "currentColor",
4190
4816
  strokeWidth: 3.6,
4191
4817
  strokeLinecap: "round",
4192
4818
  children: [
4193
- react_jsx_runtime.jsx("circle", { cx: 11, cy: 11, r: 7 }),
4194
- react_jsx_runtime.jsx("line", { x1: 21, y1: 21, x2: 16, y2: 16 })
4819
+ // centred by geometry (bbox 3.5..20.5, centre 12,12) rather than by a nudge:
4820
+ // a fractional translate leaves the glyph a half pixel off in a round button
4821
+ react_jsx_runtime.jsx("circle", { cx: 10.5, cy: 10.5, r: 7 }),
4822
+ react_jsx_runtime.jsx("line", { x1: 20.5, y1: 20.5, x2: 15.5, y2: 15.5 })
4195
4823
  ]
4196
4824
  })
4197
4825
  );
@@ -4202,13 +4830,14 @@ window.__ModuleLoader__.load({
4202
4830
  width: tbIconLg,
4203
4831
  height: tbIconLg,
4204
4832
  viewBox: "0 0 24 24",
4205
- style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
4833
+ style: { display: "block", overflow: "visible" },
4206
4834
  fill: "none",
4207
4835
  stroke: "currentColor",
4208
4836
  strokeWidth: 3.4,
4209
4837
  strokeLinecap: "round",
4210
4838
  children: [
4211
- // cross lines span 5..19, intersection (12,12) centered
4839
+ // cross lines span 5..19, intersection (12,12) centered — no nudge: a translate
4840
+ // here is what left the reader's ✕ visibly right of the round button's centre
4212
4841
  react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
4213
4842
  react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
4214
4843
  ]
@@ -4279,7 +4908,7 @@ window.__ModuleLoader__.load({
4279
4908
  var levelsPopSheetEl = (sheetOpen && (levelsOpen || levelsClosing)) ? buildLevelsPop(true) : null;
4280
4909
  // dock toggle: the triangle tips toward the side it will move TO
4281
4910
  var dockBtnEl = iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 });
4282
- // the curtain button: a surface with an arrow rising out of it
4911
+ // the curtain button: a surface with the arrow dropping out of its TOP edge
4283
4912
  var sheetBtnEl = react_jsx_runtime.jsx("button", {
4284
4913
  className: "dqt-sheet-btn",
4285
4914
  onClick: openSheet,
@@ -4317,16 +4946,22 @@ window.__ModuleLoader__.load({
4317
4946
  strokeWidth: 1.8,
4318
4947
  strokeLinecap: "round",
4319
4948
  strokeLinejoin: "round",
4320
- style: { display: "block" },
4949
+ style: { display: "block", overflow: "visible" },
4321
4950
  children: [
4322
- react_jsx_runtime.jsx("rect", { x: 1.6, y: 3.2, width: 10.8, height: 9.2, rx: 1.6 }),
4323
- react_jsx_runtime.jsx("path", { d: "M7 9.6V5.6M4.9 7.7L7 5.6l2.1 2.1" })
4951
+ // A landscape surface 12.0 x 9.0 (centred in the 14 box: 1.0..13.0 / 2.5..11.5)
4952
+ // neither the portrait slab nor the flat strip the earlier attempts were. The
4953
+ // rectangle itself carries the SAME border weight as the questions bubble (2.2,
4954
+ // via its own strokeWidth) while the arrow keeps the lighter inherited 1.8, so the
4955
+ // frame reads as the frame and the arrow stays a mark inside it. `overflow:
4956
+ // visible` because the 2.2 border on a 12-wide rect reaches x = -0.1.
4957
+ react_jsx_runtime.jsx("rect", { x: 1.0, y: 2.5, width: 12.0, height: 9.0, rx: 1.6, strokeWidth: 2.2 }),
4958
+ react_jsx_runtime.jsx("path", { d: "M7 2.5V9.1M5.2 7.3L7 9.1l1.8-1.8" })
4324
4959
  ]
4325
4960
  })
4326
4961
  });
4327
4962
  // collapse (docked panel only): thick SVG cross, nudged slightly down
4328
4963
  var headerCloseBtnEl = react_jsx_runtime.jsx("button", {
4329
- onClick: function () { setOpen(false); },
4964
+ onClick: function () { noteClosed("dock"); setOpen(false); },
4330
4965
  title: T("handle.collapse"),
4331
4966
  style: {
4332
4967
  width: 24,
@@ -4353,13 +4988,13 @@ window.__ModuleLoader__.load({
4353
4988
  width: 15,
4354
4989
  height: 15,
4355
4990
  viewBox: "0 0 24 24",
4356
- style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
4991
+ style: { display: "block", overflow: "visible" },
4357
4992
  fill: "none",
4358
4993
  stroke: "currentColor",
4359
4994
  strokeWidth: 3.4,
4360
4995
  strokeLinecap: "round",
4361
4996
  children: [
4362
- // cross lines span 5..19, intersection (12,12) centered
4997
+ // cross lines span 5..19, intersection (12,12) centered (no nudge here either)
4363
4998
  react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
4364
4999
  react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
4365
5000
  ]
@@ -4699,6 +5334,12 @@ window.__ModuleLoader__.load({
4699
5334
  "aria-label": T("a11y.list"),
4700
5335
  style: {
4701
5336
  overflowY: "auto",
5337
+ // We are the only thing that holds the reader's place in this list (see the
5338
+ // paging block): the browser's own scroll anchoring would also adjust
5339
+ // `scrollTop` when a ghost turns into a real subtree, and two compensations for
5340
+ // one growth fight each other — the browser's is invisible to our measurement,
5341
+ // so our correction would then overshoot and the content would jump UP.
5342
+ overflowAnchor: "none",
4702
5343
  // NO top padding: a sticky child is confined to its containing
4703
5344
  // block, so a padded scroll container would hold the pinned group
4704
5345
  // header that many pixels below the toolbar (a visible gap). Spacing
@@ -4762,6 +5403,10 @@ window.__ModuleLoader__.load({
4762
5403
  ]
4763
5404
  })
4764
5405
  }) : null;
5406
+ // Whether the one-shot "back to where I left this surface" button is on: measured here,
5407
+ // after every piece of this pass's chrome geometry is known, and against the memory of
5408
+ // the surface that is actually on screen (see the restore block near the top).
5409
+ var showBack = awayFromRestore(listRef.current);
4765
5410
  // the outline and the search column share one row in BOTH layouts, so the list
4766
5411
  // is never moved between parents when the curtain opens or closes
4767
5412
  var bodyRowEl = react_jsx_runtime.jsx("div", {
@@ -4819,6 +5464,7 @@ window.__ModuleLoader__.load({
4819
5464
  color: C.text,
4820
5465
  opacity: panelOpacity,
4821
5466
  clipPath: panelClip,
5467
+ transform: panelTransform,
4822
5468
  // inner shadow only (no outer): theme-aware (white bevel in dark mode,
4823
5469
  // soft shade in light) — outer shadow would be clipped by the collapse
4824
5470
  // clip-path and the panel edges.
@@ -4899,8 +5545,10 @@ window.__ModuleLoader__.load({
4899
5545
  // curtain it also clears the pushed-in search column
4900
5546
  right: (sheetOpen && sheetColW > 0 ? SHEET_SEARCH_W : 0) + 18,
4901
5547
  bottom: 12,
4902
- width: 26,
4903
- height: 26,
5548
+ // the curtain scales up every other control (24 -> 32); this floating button
5549
+ // was the one left behind at the docked size
5550
+ width: sheetOpen ? 34 : 26,
5551
+ height: sheetOpen ? 34 : 26,
4904
5552
  display: "flex",
4905
5553
  alignItems: "center",
4906
5554
  justifyContent: "center",
@@ -4924,8 +5572,8 @@ window.__ModuleLoader__.load({
4924
5572
  transition: "opacity 0.22s ease, transform 0.22s ease, background 0.18s ease, color 0.18s ease, border-color 0.18s ease"
4925
5573
  },
4926
5574
  children: react_jsx_runtime.jsx("svg", {
4927
- width: 14,
4928
- height: 14,
5575
+ width: sheetOpen ? 18 : 14,
5576
+ height: sheetOpen ? 18 : 14,
4929
5577
  viewBox: "0 0 14 14",
4930
5578
  fill: "none",
4931
5579
  stroke: "currentColor",
@@ -4939,6 +5587,67 @@ window.__ModuleLoader__.load({
4939
5587
  ]
4940
5588
  })
4941
5589
  }),
5590
+ // "back to the place I scrolled to": the mirror of the button above, in the
5591
+ // list's upper-right corner, arrow pointing UP. It appears only while the list is
5592
+ // far from the position the reader last reached by wheel/key — the follow and the
5593
+ // curtain handoff both move the outline for the reader, and until now there was no
5594
+ // way back to where they had been browsing. Same surface vocabulary and same
5595
+ // curtain scaling as the bottom button.
5596
+ react_jsx_runtime.jsx("button", {
5597
+ onClick: backToBrowse,
5598
+ className: "dqt-top-btn",
5599
+ "data-at-mine": showBack ? "off" : "on",
5600
+ title: T("outline.backToMine"),
5601
+ tabIndex: showBack ? 0 : -1,
5602
+ "aria-hidden": showBack ? undefined : "true",
5603
+ onMouseEnter: function (e) {
5604
+ e.currentTarget.style.color = C.accent;
5605
+ e.currentTarget.style.borderColor = C.groupEdgeSoft;
5606
+ },
5607
+ onMouseLeave: function (e) {
5608
+ e.currentTarget.style.color = C.text;
5609
+ e.currentTarget.style.borderColor = C.panelBorder;
5610
+ },
5611
+ style: {
5612
+ position: "absolute",
5613
+ right: (sheetOpen && sheetColW > 0 ? SHEET_SEARCH_W : 0) + 18,
5614
+ // below whatever chrome this surface has (measured, see listTop)
5615
+ top: listTop + 12,
5616
+ width: sheetOpen ? 34 : 26,
5617
+ height: sheetOpen ? 34 : 26,
5618
+ display: "flex",
5619
+ alignItems: "center",
5620
+ justifyContent: "center",
5621
+ padding: 0,
5622
+ background: "var(--dsw-specific-menu, rgba(44, 49, 60, 0.99))",
5623
+ border: "1px solid " + C.panelBorder,
5624
+ borderRadius: "50%",
5625
+ cornerShape: "round",
5626
+ color: C.text,
5627
+ cursor: "pointer",
5628
+ zIndex: 6,
5629
+ boxShadow: "0 3px 10px rgba(0, 0, 0, 0.30)",
5630
+ opacity: showBack ? 1 : 0,
5631
+ transform: showBack ? "translateY(0)" : "translateY(-6px)",
5632
+ pointerEvents: showBack ? "auto" : "none",
5633
+ transition: "opacity 0.22s ease, transform 0.22s ease, background 0.18s ease, color 0.18s ease, border-color 0.18s ease"
5634
+ },
5635
+ children: react_jsx_runtime.jsx("svg", {
5636
+ width: sheetOpen ? 18 : 14,
5637
+ height: sheetOpen ? 18 : 14,
5638
+ viewBox: "0 0 14 14",
5639
+ fill: "none",
5640
+ stroke: "currentColor",
5641
+ strokeWidth: 1.8,
5642
+ strokeLinecap: "round",
5643
+ strokeLinejoin: "round",
5644
+ style: { display: "block" },
5645
+ children: [
5646
+ react_jsx_runtime.jsx("polyline", { points: "3.5,9.5 7,6 10.5,9.5" }),
5647
+ react_jsx_runtime.jsx("line", { x1: 3.5, y1: 3, x2: 10.5, y2: 3 })
5648
+ ]
5649
+ })
5650
+ }),
4942
5651
  toastEl,
4943
5652
  // Polite live region: match stepping, result counts and cursor moves land
4944
5653
  // here. Visually hidden (`.dqt-sr`) — it exists for assistive tech only.
@@ -5138,7 +5847,14 @@ window.__ModuleLoader__.load({
5138
5847
  title: T("sheet.openHandle"),
5139
5848
  onClick: function () {
5140
5849
  var el = sheetHandleRef.current;
5141
- if (el && el.parentElement && el.parentElement.style) el.parentElement.style.transform = "scaleY(0.12)";
5850
+ // Pressed FLUSH into the line — `scaleY(0)`, not a small remainder. At 0.12 the
5851
+ // 17px droplet still had ~2px of dome sitting on the strip once the press had
5852
+ // landed, and because the element only leaves the tree a beat later, that
5853
+ // sliver hung there motionless and was then removed in a single frame: the
5854
+ // "it stops, then it is suddenly gone" the reader reported. Collapsed to zero
5855
+ // it sinks out of sight exactly when the press lands, and its hit area goes
5856
+ // with it.
5857
+ if (el && el.parentElement && el.parentElement.style) el.parentElement.style.transform = "scaleY(0)";
5142
5858
  setSheetHandleGone(false);
5143
5859
  // The press has to be SEEN before the curtain takes the stage. With the panel
5144
5860
  // open it already is: the panel's own 300ms tuck comes first. Collapsed, the
@@ -5898,9 +6614,13 @@ window.__ModuleLoader__.load({
5898
6614
  // A turn the paged event window has not loaded yet: the host outline still
5899
6615
  // names it (number + bounded previews), so the outline covers the whole
5900
6616
  // session. Clicking pages that turn in through the session's jump loader.
5901
- function renderGhostGroup(g, gi, C, extra) {
5902
- extra = extra || {};
5903
- var children = [
6617
+ // What an unloaded group shows: the badge, the prompt, and the host's own response
6618
+ // preview. That preview is budgeted by the host at up to THREE rail-card lines, so it
6619
+ // is rendered at three lines and legibly — clamped to two at 0.65 opacity it read as
6620
+ // "there is nothing here but my own question", which is exactly what the reader
6621
+ // reported.
6622
+ function ghostBody(C, g) {
6623
+ var out = [
5904
6624
  react_jsx_runtime.jsx("div", {
5905
6625
  style: { display: "flex", alignItems: "center", gap: 5, minWidth: 0 },
5906
6626
  children: [
@@ -5916,11 +6636,16 @@ window.__ModuleLoader__.load({
5916
6636
  })
5917
6637
  ];
5918
6638
  if (g.preview) {
5919
- children.push(react_jsx_runtime.jsx("div", {
5920
- style: { fontSize: 10, lineHeight: "13px", color: C.muted, opacity: 0.65, marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" },
6639
+ out.push(react_jsx_runtime.jsx("div", {
6640
+ style: { fontSize: 10.5, lineHeight: "14px", color: C.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical" },
5921
6641
  children: g.preview
5922
6642
  }));
5923
6643
  }
6644
+ return out;
6645
+ }
6646
+ function renderGhostGroup(g, gi, C, extra) {
6647
+ extra = extra || {};
6648
+ var children = ghostBody(C, g);
5924
6649
  return react_jsx_runtime.jsx("div", {
5925
6650
  onClick: function () { extra.onOpenTurn(g.turn, g.seq); },
5926
6651
  onMouseEnter: function (e) {
@@ -6256,7 +6981,8 @@ window.__ModuleLoader__.load({
6256
6981
  field("handle", T("settings.handle"), T("settings.handle.tip"), handleControl, false),
6257
6982
  field("fuzzy", T("settings.fuzzy"), "", onoff(prefs.fuzzy, function (v) { commit({ fuzzy: v }); }), true),
6258
6983
  field("hover", T("settings.hover"), "", onoff(prefs.hover, function (v) { commit({ hover: v }); }), true),
6259
- field("remember", T("settings.remember"), T("settings.remember.tip"), onoff(prefs.remember, function (v) { commit({ remember: v }); }), true),
6984
+ field("remember", T("settings.remember"), "", onoff(prefs.remember, function (v) { commit({ remember: v }); }), true),
6985
+ field("autoLoad", T("settings.autoLoad"), "", onoff(prefs.autoLoad, function (v) { commit({ autoLoad: v }); }), true),
6260
6986
  field("debug", T("settings.debug"), T("settings.debug.tip"), onoff(prefs.debug, function (v) { commit({ debug: v }); }), true, true)
6261
6987
  ];
6262
6988
  return react_jsx_runtime.jsxs("li", {
@@ -6461,7 +7187,10 @@ window.__ModuleLoader__.load({
6461
7187
  // not a detached pill). It grows out of the line when it appears — after a
6462
7188
  // retract as much as on first paint — and the click squeezes it flat against
6463
7189
  // the line before the curtain takes over (see the element's own transform).
6464
- "@keyframes dqt-handle-pop{from{transform:scaleY(0.06)}to{transform:scaleY(1)}}" +
7190
+ // Both ends of the pair are flush with the line — 0 -> 1 here, 1 -> 0 on the
7191
+ // press — so the two directions are one motion and its rewind in their
7192
+ // endpoints as well as in their curves.
7193
+ "@keyframes dqt-handle-pop{from{transform:scaleY(0)}to{transform:scaleY(1)}}" +
6465
7194
  ".dqt-sheet-handle{transform-origin:50% 100%}" +
6466
7195
  // coming back it is the press read backwards: FAST OUT OF THE LINE — 74% of the
6467
7196
  // easing is done 30ms in — and then the rest settles over the remaining 170ms.