deepseek-harness-background 0.5.3 → 0.5.4

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
@@ -1125,41 +1125,41 @@ window.__ModuleLoader__.load({
1125
1125
  document.head.appendChild(tag);
1126
1126
  }
1127
1127
  var SettingsRow_module_css_default = {
1128
- "clearBtn": "-ozqdq_clearBtn",
1129
- "slider": "-ozqdq_slider",
1130
- "sourceRow": "-ozqdq_sourceRow",
1128
+ "enabledBadge": "-ozqdq_enabledBadge",
1129
+ "previewImg": "-ozqdq_previewImg",
1130
+ "btnPrimary": "-ozqdq_btnPrimary",
1131
+ "hint": "-ozqdq_hint",
1132
+ "segActive": "-ozqdq_segActive",
1133
+ "clearRow": "-ozqdq_clearRow",
1134
+ "segBtn": "-ozqdq_segBtn",
1135
+ "previewGlassText": "-ozqdq_previewGlassText",
1136
+ "btn": "-ozqdq_btn",
1131
1137
  "preview": "-ozqdq_preview",
1132
- "switch": "-ozqdq_switch",
1133
- "segGroup": "-ozqdq_segGroup",
1138
+ "clearBtn": "-ozqdq_clearBtn",
1139
+ "urlInput": "-ozqdq_urlInput",
1134
1140
  "btnDanger": "-ozqdq_btnDanger",
1135
- "previewEmpty": "-ozqdq_previewEmpty",
1136
- "titleRow": "-ozqdq_titleRow",
1137
- "sliderRow": "-ozqdq_sliderRow",
1138
- "sliderValue": "-ozqdq_sliderValue",
1139
- "segBtn": "-ozqdq_segBtn",
1140
- "previewGlass": "-ozqdq_previewGlass",
1141
- "row": "-ozqdq_row",
1142
- "error": "-ozqdq_error",
1143
- "previewScrim": "-ozqdq_previewScrim",
1141
+ "urlRow": "-ozqdq_urlRow",
1144
1142
  "switchKnob": "-ozqdq_switchKnob",
1145
- "urlInput": "-ozqdq_urlInput",
1146
- "btnPrimary": "-ozqdq_btnPrimary",
1147
- "segActive": "-ozqdq_segActive",
1143
+ "segRow": "-ozqdq_segRow",
1148
1144
  "segLabel": "-ozqdq_segLabel",
1149
- "urlRow": "-ozqdq_urlRow",
1150
- "previewGlassText": "-ozqdq_previewGlassText",
1145
+ "previewGlass": "-ozqdq_previewGlass",
1146
+ "header": "-ozqdq_header",
1151
1147
  "sectionLabel": "-ozqdq_sectionLabel",
1152
- "enabledBadge": "-ozqdq_enabledBadge",
1153
- "segRow": "-ozqdq_segRow",
1154
- "previewImg": "-ozqdq_previewImg",
1155
- "btn": "-ozqdq_btn",
1156
1148
  "sliderLabel": "-ozqdq_sliderLabel",
1157
- "header": "-ozqdq_header",
1158
- "clearRow": "-ozqdq_clearRow",
1159
1149
  "desc": "-ozqdq_desc",
1150
+ "sliderValue": "-ozqdq_sliderValue",
1160
1151
  "section": "-ozqdq_section",
1161
- "hint": "-ozqdq_hint",
1162
- "title": "-ozqdq_title"
1152
+ "titleRow": "-ozqdq_titleRow",
1153
+ "previewEmpty": "-ozqdq_previewEmpty",
1154
+ "sliderRow": "-ozqdq_sliderRow",
1155
+ "previewScrim": "-ozqdq_previewScrim",
1156
+ "slider": "-ozqdq_slider",
1157
+ "segGroup": "-ozqdq_segGroup",
1158
+ "error": "-ozqdq_error",
1159
+ "title": "-ozqdq_title",
1160
+ "row": "-ozqdq_row",
1161
+ "sourceRow": "-ozqdq_sourceRow",
1162
+ "switch": "-ozqdq_switch"
1163
1163
  };
1164
1164
  //#endregion
1165
1165
  //#region src/client/SettingsRow.tsx
@@ -1695,393 +1695,131 @@ window.__ModuleLoader__.load({
1695
1695
  ]
1696
1696
  });
1697
1697
  }
1698
- //#endregion
1699
- //#region src/client/timeline/jump.ts
1700
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1701
- /** How long one jump may keep paging older history before giving up. */
1702
- const JUMP_PAGE_DEADLINE_MS = 3e4;
1703
- /** How long to wait for the chat view to commit the target row. */
1704
- const JUMP_ROW_WAIT_MS = 3e3;
1705
- /** Settle between consecutive pages so a resolver without loadingOlder cannot hot-spin the loop. */
1706
- const PAGE_COOLDOWN_MS = 50;
1707
- /** Distance from the scrollport top the official ChatView lands a jumped row at. */
1708
- const OFFICIAL_LANDING_INSET_PX = 24;
1709
- /** Distances under this snap instead of animating. */
1710
- const MIN_GLIDE_DISTANCE_PX = 8;
1711
- /** Glide duration floor / cap — a far-boundary jump stays a tangible slide. */
1712
- const MIN_GLIDE_MS = 280;
1713
- const MAX_GLIDE_MS = 900;
1714
- /** Every mounted conversation scrollport (multi-column layouts have several). */
1715
- function conversationScrollports() {
1716
- return Array.from(document.querySelectorAll("[data-conversation-scroll]"));
1717
- }
1718
1698
  /**
1719
- * Whether the target chat row is committed in ANY mounted conversation
1720
- * scrollport. Kernel generations disagree on where the node table lives
1721
- * (0.1.1: `SessionSnapshot.chat.nodes`; 0.1.2+: the `useChat` ChatSnapshot
1722
- * node store, unreachable from `session.getSnapshot()` — its SessionSnapshot
1723
- * faces no node table at all), but the rendered DOM is the ground truth both
1724
- * share, so a jump probes it FIRST before deciding whether paging is needed.
1725
- * @param anchorKey - chat anchor key of the target row.
1699
+ * The rail's own inset, read off the custom property the official component
1700
+ * publishes (`--turn-rail-inset`). Falls back to the official constant.
1701
+ * @param rail - the rail element (or null).
1726
1702
  */
1727
- function rowRenderedInDom(anchorKey) {
1728
- const selector = `[data-chat-anchor-key="${CSS.escape(anchorKey)}"]`;
1729
- for (const scrollport of conversationScrollports()) if (scrollport.querySelector(selector) !== null) return true;
1730
- return false;
1703
+ function railInsetOf(rail) {
1704
+ if (rail === null) return 6;
1705
+ const px = Number.parseFloat(rail.style.getPropertyValue("--turn-rail-inset"));
1706
+ return Number.isFinite(px) && px >= 0 ? px : 6;
1731
1707
  }
1732
1708
  /**
1733
- * Poll the conversation DOM until the target row is committed. The session
1734
- * store updates BEFORE React renders the prepended page, so the old one-shot
1735
- * query right after loadOlder() resolved could miss the row and silently
1736
- * give up — the reported "click does nothing until the conversation is
1737
- * nudged (scroll/click)" behavior.
1738
- * @param key - chat anchor key to look for.
1739
- * @param timeoutMs - polling budget.
1709
+ * Whether `rail` is the frame-style rail (dsh >= 0.1.2-alpha.3): marks sit
1710
+ * at a fixed pitch and scroll INSIDE the frame, whose scroller offset the
1711
+ * component publishes inline as `--turn-scroll-top` — every render, resting
1712
+ * at `0px` included. The compressing alpha.1/2 rail never publishes that
1713
+ * property, so its presence is the generation test. The legacy port paints
1714
+ * its own `--dsbt-*` properties and can never trip this.
1715
+ * @param rail - the rail element (or null).
1740
1716
  */
1741
- async function waitForChatRow(key, timeoutMs) {
1742
- const selector = `[data-chat-anchor-key="${CSS.escape(key)}"]`;
1743
- const deadline = Date.now() + timeoutMs;
1744
- for (;;) {
1745
- for (const scrollport of conversationScrollports()) {
1746
- const row = scrollport.querySelector(selector);
1747
- if (row !== null) return row;
1748
- }
1749
- if (Date.now() >= deadline) return null;
1750
- await delay(40);
1751
- }
1752
- }
1753
- /** Maximum scrollable offset of a conversation scrollport. */
1754
- function scrollFloorOf(el) {
1755
- return Math.max(0, el.scrollHeight - el.clientHeight);
1717
+ function isFrameRail(rail) {
1718
+ return rail !== null && rail.style.getPropertyValue("--turn-scroll-top") !== "";
1756
1719
  }
1757
1720
  /**
1758
- * Destination scrollTop that centers `row` inside `el` — the same geometry
1759
- * as scrollIntoView({ block: 'center' }), computed directly so the glide owns
1760
- * the scroll position instead of the browser.
1761
- * @param el - the conversation scrollport.
1762
- * @param row - the target chat row.
1721
+ * Resolve the entry under a pointer Y on the frame-style rail — the official
1722
+ * alpha.3 geometry verbatim (`itemAtPointer` in that generation's
1723
+ * TurnNavigator.tsx): marks live in the scrolled ladder at a fixed 10px
1724
+ * pitch, so the click offset adds the scroller offset. Read off the published
1725
+ * `--turn-scroll-top` var rather than the scroller element itself — the same
1726
+ * metric the official preview positioning uses, so any one-frame render lag
1727
+ * affects both identically.
1728
+ * @param count - number of entries the rail shows.
1729
+ * @param rail - the rail element.
1730
+ * @param clientY - pointer viewport Y.
1763
1731
  */
1764
- function centeredScrollTopFor(el, row) {
1765
- const floor = scrollFloorOf(el);
1766
- const target = el.scrollTop + (row.getBoundingClientRect().top - el.getBoundingClientRect().top) - (el.clientHeight - row.offsetHeight) / 2;
1767
- if (!Number.isFinite(target)) return el.scrollTop;
1768
- return Math.max(0, Math.min(floor, target));
1732
+ function frameIndexAtPointer(count, rail, clientY) {
1733
+ const rect = rail.getBoundingClientRect();
1734
+ const inset = railInsetOf(rail);
1735
+ const raw = Number.parseFloat(rail.style.getPropertyValue("--turn-scroll-top"));
1736
+ const scrollTop = Number.isFinite(raw) ? raw : 0;
1737
+ const offset = clientY - rect.top + scrollTop - inset;
1738
+ return Math.max(0, Math.min(count - 1, Math.round(offset / 10)));
1769
1739
  }
1770
1740
  /**
1771
- * Destination scrollTop the official ChatView uses for a navigation jump:
1772
- * the row's top minus a 24px inset. Kept as a named resolver so the enhancer
1773
- * lands exactly where the official would — only animated instead of assigned.
1774
- * @param el - the conversation scrollport.
1775
- * @param row - the target chat row.
1741
+ * Resolve the gesture that produced a rail click to an entry index.
1742
+ *
1743
+ * Keyboard activation targets the focused mark button (recovered by its
1744
+ * position among the rail's buttons); a mouse click carries coordinates and
1745
+ * resolves through the rail geometry — the frame-style mapping over the
1746
+ * published `--turn-scroll-top`. Returns -1 when neither applies (a
1747
+ * keyboard target outside the rail, or a rail of an unsupported generation,
1748
+ * which the kernel's own handler then owns).
1749
+ *
1750
+ * Assumption: the mark ticks are the ONLY buttons under the rail — the
1751
+ * official rail renders marks exclusively and its preview tooltip carries
1752
+ * none. A future non-mark control added inside the rail would shift the
1753
+ * button index and must revisit this.
1754
+ *
1755
+ * @param rail - the rail element.
1756
+ * @param event - the click event.
1776
1757
  */
1777
- function officialTargetTopFor(el, row) {
1778
- const floor = scrollFloorOf(el);
1779
- const target = el.scrollTop + (row.getBoundingClientRect().top - el.getBoundingClientRect().top) - OFFICIAL_LANDING_INSET_PX;
1780
- if (!Number.isFinite(target)) return el.scrollTop;
1781
- return Math.max(0, Math.min(floor, target));
1758
+ function indexForEvent(rail, event) {
1759
+ const target = event.target;
1760
+ if (target instanceof Element) {
1761
+ const button = target.closest("button");
1762
+ if (button !== null && rail.contains(button)) {
1763
+ const index = Array.from(rail.querySelectorAll("button")).indexOf(button);
1764
+ if (index >= 0) return index;
1765
+ }
1766
+ }
1767
+ const count = rail.querySelectorAll("button").length;
1768
+ if (count === 0) return -1;
1769
+ return isFrameRail(rail) ? frameIndexAtPointer(count, rail, event.clientY) : -1;
1782
1770
  }
1783
1771
  /**
1784
- * Glide duration for a distance: longer slides take longer, bounded to
1785
- * [MIN_GLIDE_MS, MAX_GLIDE_MS] (a deep-history jump reads as a deliberate
1786
- * glide, not a sluggish race).
1787
- * @param distance - px to travel.
1772
+ * Validate the official navigation array crossing the slot boundary. The
1773
+ * value is produced by the kernel and may be absent, partial, or from an
1774
+ * older build, so every field is checked before use.
1775
+ * @param value - the raw `useChat(s => s.navigation.items())` result.
1788
1776
  */
1789
- function glideDurationFor(distance) {
1790
- if (!Number.isFinite(distance)) return MIN_GLIDE_MS;
1791
- return Math.round(Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, 240 + Math.abs(distance) * .14)));
1777
+ function normalizeNavigationItems(value) {
1778
+ if (!Array.isArray(value)) return [];
1779
+ const out = [];
1780
+ const seen = /* @__PURE__ */ new Set();
1781
+ for (const item of value) {
1782
+ if (item === null || typeof item !== "object") continue;
1783
+ const rec = item;
1784
+ if (typeof rec.turn !== "number" || !Number.isFinite(rec.turn)) continue;
1785
+ if (typeof rec.anchorKey !== "string" || rec.anchorKey === "") continue;
1786
+ if (seen.has(rec.turn)) continue;
1787
+ seen.add(rec.turn);
1788
+ out.push({
1789
+ turn: rec.turn,
1790
+ anchorKey: rec.anchorKey,
1791
+ prompt: typeof rec.prompt === "string" ? rec.prompt : "",
1792
+ response: typeof rec.response === "string" ? rec.response : ""
1793
+ });
1794
+ }
1795
+ return out;
1792
1796
  }
1793
1797
  /**
1794
- * Detach the host's bottom-follow state when the reader currently sits inside
1795
- * its follow zone: assign the scroll position just outside the boundary. That
1796
- * one synchronous scroll event settles at-bottom to false (moved-by-reader +
1797
- * outside the zone) so a tip-moved re-render cannot yank the glide back to
1798
- * the floor. No-op when already above the zone or when the assignment cannot
1799
- * move (content shorter than the viewport).
1800
- * @param el - the conversation scrollport.
1801
- * @param followZonePx - the host's own follow threshold.
1798
+ * Validate one `turnOutline` wire value into its entries. The value is the
1799
+ * host projection's wire view (an array of `{ turn, seq, prompt, response }`
1800
+ * — see `dsh-session-turn-outline`) read through `useProjection`, so it may
1801
+ * be absent (pre-baseline, or an older kernel that never registers the key —
1802
+ * which reads `undefined`), partial, or damaged. `turn` and `seq` are the
1803
+ * load-bearing fields — a mark cannot exist or page without them — and a
1804
+ * damaged one drops the entry; the previews are decorative and degrade to ''.
1805
+ * @param value - the raw `useProjection('turnOutline')` value.
1802
1806
  */
1803
- function detachBottomFollow(el, followZonePx = 24) {
1804
- const floor = scrollFloorOf(el);
1805
- if (el.scrollTop < floor - followZonePx) return false;
1806
- const detached = Math.max(0, floor - followZonePx - 1);
1807
- if (Math.abs(el.scrollTop - detached) < 1) return false;
1808
- el.scrollTop = detached;
1809
- return true;
1810
- }
1811
- /**
1812
- * Wait until a detach assignment's scroll event has been dispatched AND
1813
- * consumed by the host's scroll handler: scroll events fire on a later frame
1814
- * than the assignment, and a paging commit's layout effect runs before that
1815
- * dispatch — it would re-run the follow logic while the at-bottom flag is
1816
- * still true and drag the viewport to the growing floor, undoing the detach
1817
- * (two rAFs + a macrotask bracket the dispatch window).
1818
- */
1819
- function settleScrollEvents() {
1820
- if (typeof requestAnimationFrame !== "function") return Promise.resolve();
1821
- return new Promise((resolve) => {
1822
- requestAnimationFrame(() => requestAnimationFrame(() => window.setTimeout(resolve, 0)));
1823
- });
1824
- }
1825
- /**
1826
- * Animate el.scrollTop toward target with an ease-in-out cubic profile.
1827
- * Wheel / touch / keyboard-scroll input cancels the glide so the reader
1828
- * takes over immediately. Resolves true when the glide completed, false
1829
- * when the reader cancelled it.
1830
- * @param el - the conversation scrollport.
1831
- * @param target - destination scrollTop.
1832
- * @param durationMs - animation budget.
1833
- */
1834
- function animateScrollTop(el, target, durationMs) {
1835
- return new Promise((resolve) => {
1836
- const start = el.scrollTop;
1837
- const delta = target - start;
1838
- let done = false;
1839
- let frame = 0;
1840
- const finish = (completed) => {
1841
- if (done) return;
1842
- done = true;
1843
- el.removeEventListener("wheel", onWheel);
1844
- el.removeEventListener("touchstart", onTouch);
1845
- window.removeEventListener("keydown", onKey);
1846
- if (frame !== 0) cancelAnimationFrame(frame);
1847
- resolve(completed);
1848
- };
1849
- const onWheel = () => finish(false);
1850
- const onTouch = () => finish(false);
1851
- const onKey = (e) => {
1852
- if ([
1853
- "ArrowUp",
1854
- "ArrowDown",
1855
- "PageUp",
1856
- "PageDown",
1857
- "Home",
1858
- "End",
1859
- " "
1860
- ].includes(e.key)) finish(false);
1861
- };
1862
- el.addEventListener("wheel", onWheel, { passive: true });
1863
- el.addEventListener("touchstart", onTouch, { passive: true });
1864
- window.addEventListener("keydown", onKey);
1865
- if (Math.abs(delta) < .5 || durationMs <= 0) {
1866
- el.scrollTop = target;
1867
- finish(true);
1868
- return;
1869
- }
1870
- const t0 = performance.now();
1871
- const ease = (x) => x < .5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
1872
- const step = (now) => {
1873
- if (done) return;
1874
- const t = Math.min(1, (now - t0) / durationMs);
1875
- el.scrollTop = start + delta * ease(t);
1876
- if (t < 1) frame = requestAnimationFrame(step);
1877
- else finish(true);
1878
- };
1879
- frame = requestAnimationFrame(step);
1880
- });
1881
- }
1882
- /**
1883
- * Ensure the target node is loaded, wait for the chat view to commit its row
1884
- * into the DOM, then glide it into view. Paging uses a time budget instead
1885
- * of a fixed iteration guard (each in-flight page burned part of that guard,
1886
- * so slow history could exhaust it and silently fail).
1887
- *
1888
- * @param sessionsService - the runtime sessions service.
1889
- * @param sessionId - the session owning the target row.
1890
- * @param anchorKey - chat anchor key of the target row.
1891
- * @param options - follow zone, landing resolver, paging scopes and budgets.
1892
- * @returns whether the row was reached (false when it never rendered or the
1893
- * reader took the scrollport back mid-glide).
1894
- */
1895
- async function jumpToMessage(sessionsService, sessionId, anchorKey, options = {}) {
1896
- const session = sessionsService.binding(sessionId)?.session;
1897
- if (session === void 0) return false;
1898
- const followZonePx = options.followZonePx ?? 24;
1899
- const resolveTarget = options.targetTop ?? centeredScrollTopFor;
1900
- const pageDeadlineMs = options.pageDeadlineMs ?? JUMP_PAGE_DEADLINE_MS;
1901
- const rowWaitMs = options.rowWaitMs ?? JUMP_ROW_WAIT_MS;
1902
- const ports = options.scrollport != null ? [options.scrollport] : conversationScrollports();
1903
- let detachedAny = false;
1904
- for (const el of ports) if (detachBottomFollow(el, followZonePx)) {
1905
- el.dispatchEvent(new Event("scroll"));
1906
- detachedAny = true;
1907
- }
1908
- if (detachedAny) await settleScrollEvents();
1909
- const deadline = Date.now() + pageDeadlineMs;
1910
- for (;;) {
1911
- if (rowRenderedInDom(anchorKey)) break;
1912
- const snapshot = session.getSnapshot();
1913
- if (snapshot?.hasMore !== true) break;
1914
- if (Date.now() >= deadline) break;
1915
- if (snapshot.loadingOlder === true) {
1916
- await delay(50);
1917
- continue;
1918
- }
1919
- if (snapshot.openState !== void 0 && snapshot.openState !== "open") {
1920
- await delay(100);
1921
- continue;
1922
- }
1923
- try {
1924
- await session.loadOlder();
1925
- } catch (error) {
1926
- console.warn("[deepseek-harness-background] timeline jump paging failed:", error);
1927
- break;
1928
- }
1929
- await delay(PAGE_COOLDOWN_MS);
1930
- }
1931
- const row = await waitForChatRow(anchorKey, rowWaitMs);
1932
- if (row === null) {
1933
- console.warn(`[deepseek-harness-background] timeline jump target never rendered: ${anchorKey}`);
1934
- return false;
1935
- }
1936
- const reducedMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1937
- const scroller = row.closest("[data-conversation-scroll]");
1938
- if (scroller === null) {
1939
- row.scrollIntoView({
1940
- behavior: "auto",
1941
- block: "center"
1942
- });
1943
- if (!reducedMotion) row.scrollIntoView({
1944
- behavior: "smooth",
1945
- block: "center"
1946
- });
1947
- return true;
1948
- }
1949
- const target = resolveTarget(scroller, row);
1950
- if (reducedMotion || Math.abs(target - scroller.scrollTop) < MIN_GLIDE_DISTANCE_PX) {
1951
- scroller.scrollTop = target;
1952
- return true;
1953
- }
1954
- if (detachBottomFollow(scroller, followZonePx)) {
1955
- scroller.dispatchEvent(new Event("scroll"));
1956
- await settleScrollEvents();
1957
- }
1958
- return await animateScrollTop(scroller, target, glideDurationFor(Math.abs(target - scroller.scrollTop)));
1959
- }
1960
- /**
1961
- * The rail's own inset, read off the custom property the official component
1962
- * publishes (`--turn-rail-inset`). Falls back to the official constant.
1963
- * @param rail - the rail element (or null).
1964
- */
1965
- function railInsetOf(rail) {
1966
- if (rail === null) return 6;
1967
- const px = Number.parseFloat(rail.style.getPropertyValue("--turn-rail-inset"));
1968
- return Number.isFinite(px) && px >= 0 ? px : 6;
1969
- }
1970
- /**
1971
- * Whether `rail` is the frame-style rail (dsh >= 0.1.2-alpha.3): marks sit
1972
- * at a fixed pitch and scroll INSIDE the frame, whose scroller offset the
1973
- * component publishes inline as `--turn-scroll-top` — every render, resting
1974
- * at `0px` included. The compressing alpha.1/2 rail never publishes that
1975
- * property, so its presence is the generation test. The legacy port paints
1976
- * its own `--dsbt-*` properties and can never trip this.
1977
- * @param rail - the rail element (or null).
1978
- */
1979
- function isFrameRail(rail) {
1980
- return rail !== null && rail.style.getPropertyValue("--turn-scroll-top") !== "";
1981
- }
1982
- /**
1983
- * Resolve the entry under a pointer Y on the frame-style rail — the official
1984
- * alpha.3 geometry verbatim (`itemAtPointer` in that generation's
1985
- * TurnNavigator.tsx): marks live in the scrolled ladder at a fixed 10px
1986
- * pitch, so the click offset adds the scroller offset. Read off the published
1987
- * `--turn-scroll-top` var rather than the scroller element itself — the same
1988
- * metric the official preview positioning uses, so any one-frame render lag
1989
- * affects both identically.
1990
- * @param count - number of entries the rail shows.
1991
- * @param rail - the rail element.
1992
- * @param clientY - pointer viewport Y.
1993
- */
1994
- function frameIndexAtPointer(count, rail, clientY) {
1995
- const rect = rail.getBoundingClientRect();
1996
- const inset = railInsetOf(rail);
1997
- const raw = Number.parseFloat(rail.style.getPropertyValue("--turn-scroll-top"));
1998
- const scrollTop = Number.isFinite(raw) ? raw : 0;
1999
- const offset = clientY - rect.top + scrollTop - inset;
2000
- return Math.max(0, Math.min(count - 1, Math.round(offset / 10)));
2001
- }
2002
- /**
2003
- * Resolve the gesture that produced a rail click to an entry index.
2004
- *
2005
- * Keyboard activation targets the focused mark button (recovered by its
2006
- * position among the rail's buttons); a mouse click carries coordinates and
2007
- * resolves through the rail geometry — the frame-style mapping over the
2008
- * published `--turn-scroll-top`. Returns -1 when neither applies (a
2009
- * keyboard target outside the rail, or a rail of an unsupported generation,
2010
- * which the kernel's own handler then owns).
2011
- *
2012
- * Assumption: the mark ticks are the ONLY buttons under the rail — the
2013
- * official rail renders marks exclusively and its preview tooltip carries
2014
- * none. A future non-mark control added inside the rail would shift the
2015
- * button index and must revisit this.
2016
- *
2017
- * @param rail - the rail element.
2018
- * @param event - the click event.
2019
- */
2020
- function indexForEvent(rail, event) {
2021
- const target = event.target;
2022
- if (target instanceof Element) {
2023
- const button = target.closest("button");
2024
- if (button !== null && rail.contains(button)) {
2025
- const index = Array.from(rail.querySelectorAll("button")).indexOf(button);
2026
- if (index >= 0) return index;
2027
- }
2028
- }
2029
- const count = rail.querySelectorAll("button").length;
2030
- if (count === 0) return -1;
2031
- return isFrameRail(rail) ? frameIndexAtPointer(count, rail, event.clientY) : -1;
2032
- }
2033
- /**
2034
- * Validate the official navigation array crossing the slot boundary. The
2035
- * value is produced by the kernel and may be absent, partial, or from an
2036
- * older build, so every field is checked before use.
2037
- * @param value - the raw `useChat(s => s.navigation.items())` result.
2038
- */
2039
- function normalizeNavigationItems(value) {
2040
- if (!Array.isArray(value)) return [];
2041
- const out = [];
2042
- const seen = /* @__PURE__ */ new Set();
2043
- for (const item of value) {
2044
- if (item === null || typeof item !== "object") continue;
2045
- const rec = item;
2046
- if (typeof rec.turn !== "number" || !Number.isFinite(rec.turn)) continue;
2047
- if (typeof rec.anchorKey !== "string" || rec.anchorKey === "") continue;
2048
- if (seen.has(rec.turn)) continue;
2049
- seen.add(rec.turn);
2050
- out.push({
2051
- turn: rec.turn,
2052
- anchorKey: rec.anchorKey,
2053
- prompt: typeof rec.prompt === "string" ? rec.prompt : "",
2054
- response: typeof rec.response === "string" ? rec.response : ""
2055
- });
2056
- }
2057
- return out;
2058
- }
2059
- /**
2060
- * Validate one `turnOutline` wire value into its entries. The value is the
2061
- * host projection's wire view (an array of `{ turn, seq, prompt, response }`
2062
- * — see `dsh-session-turn-outline`) read through `useProjection`, so it may
2063
- * be absent (pre-baseline, or an older kernel that never registers the key —
2064
- * which reads `undefined`), partial, or damaged. `turn` and `seq` are the
2065
- * load-bearing fields — a mark cannot exist or page without them — and a
2066
- * damaged one drops the entry; the previews are decorative and degrade to ''.
2067
- * @param value - the raw `useProjection('turnOutline')` value.
2068
- */
2069
- function normalizeOutlineItems(value) {
2070
- if (!Array.isArray(value)) return [];
2071
- const out = [];
2072
- for (const item of value) {
2073
- if (item === null || typeof item !== "object") continue;
2074
- const rec = item;
2075
- if (typeof rec.turn !== "number" || !Number.isSafeInteger(rec.turn) || rec.turn < 0) continue;
2076
- if (typeof rec.seq !== "number" || !Number.isSafeInteger(rec.seq) || rec.seq < 0) continue;
2077
- out.push({
2078
- turn: rec.turn,
2079
- seq: rec.seq,
2080
- prompt: typeof rec.prompt === "string" ? rec.prompt : "",
2081
- response: typeof rec.response === "string" ? rec.response : ""
2082
- });
2083
- }
2084
- return out;
1807
+ function normalizeOutlineItems(value) {
1808
+ if (!Array.isArray(value)) return [];
1809
+ const out = [];
1810
+ for (const item of value) {
1811
+ if (item === null || typeof item !== "object") continue;
1812
+ const rec = item;
1813
+ if (typeof rec.turn !== "number" || !Number.isSafeInteger(rec.turn) || rec.turn < 0) continue;
1814
+ if (typeof rec.seq !== "number" || !Number.isSafeInteger(rec.seq) || rec.seq < 0) continue;
1815
+ out.push({
1816
+ turn: rec.turn,
1817
+ seq: rec.seq,
1818
+ prompt: typeof rec.prompt === "string" ? rec.prompt : "",
1819
+ response: typeof rec.response === "string" ? rec.response : ""
1820
+ });
1821
+ }
1822
+ return out;
2085
1823
  }
2086
1824
  /**
2087
1825
  * Rebuild the rail's full mark ladder exactly as the official ChatView does
@@ -2161,101 +1899,357 @@ window.__ModuleLoader__.load({
2161
1899
  * React-owned subtree and no official class or paint is overridden.
2162
1900
  */
2163
1901
  /**
2164
- * Structural anchor of the official rail — its inline style carries the
2165
- * frame's own metric (`--turn-natural-height`), published on every supported
2166
- * kernel (dsh >= 0.1.2-rc.1). Whether the found rail is the supported
2167
- * frame-style generation is then told apart by isFrameRail, never by a
2168
- * version compare.
1902
+ * Structural anchor of the official rail — its inline style carries the
1903
+ * frame's own metric (`--turn-natural-height`), published on every supported
1904
+ * kernel (dsh >= 0.1.2-rc.1). Whether the found rail is the supported
1905
+ * frame-style generation is then told apart by isFrameRail, never by a
1906
+ * version compare.
1907
+ */
1908
+ const OFFICIAL_RAIL_SELECTOR = "[data-conversation-scroll] nav[style*=\"--turn-natural-height\"]";
1909
+ /**
1910
+ * Rails claimed by an enhancer instance, keyed by the owning instance's
1911
+ * claim token. One session-scoped enhancer mounts per session; without a
1912
+ * claim two instances would both attach capture listeners to the FIRST rail
1913
+ * in the document (multi-column layouts) and one of them — not necessarily
1914
+ * the one owning the reader's column — would win the click. The token
1915
+ * distinguishes "already mine" (the poll keeps it) from "someone else's"
1916
+ * (this instance must stand down). Weak: a replaced rail element disappears
1917
+ * as soon as the browser collects it, so remounts need no bookkeeping.
1918
+ */
1919
+ const claimedRails = /* @__PURE__ */ new WeakMap();
1920
+ /** How often the official rail is looked for (it mounts with the chat view). */
1921
+ const RAIL_POLL_MS = 400;
1922
+ /**
1923
+ * The enhancer: no visual output, only the behaviour fixes the running rail
1924
+ * generation lacks.
1925
+ * @param props - dock slot props plus the enabled gate.
1926
+ * @returns null (always).
1927
+ */
1928
+ function OfficialTimelineEnhancer(props) {
1929
+ const { jumpToAnchor, useChat, useProjection, enabled } = props;
1930
+ const ladder = mergeRailItems(normalizeNavigationItems(useChat === void 0 ? void 0 : useChat((snapshot) => {
1931
+ const nav = snapshot?.navigation;
1932
+ return typeof nav?.items === "function" ? nav.items() : void 0;
1933
+ })), useProjection === void 0 ? void 0 : useProjection("turnOutline"));
1934
+ const [rail, setRail] = react.default.useState(null);
1935
+ const ladderRef = react.default.useRef(ladder);
1936
+ ladderRef.current = ladder;
1937
+ const railRef = react.default.useRef(rail);
1938
+ railRef.current = rail;
1939
+ const jumpToAnchorRef = react.default.useRef(jumpToAnchor);
1940
+ jumpToAnchorRef.current = jumpToAnchor;
1941
+ const claimToken = react.default.useMemo(() => Symbol("dsh-bg-timeline-claim"), []);
1942
+ react.default.useEffect(() => {
1943
+ const check = () => {
1944
+ const found = document.querySelector(OFFICIAL_RAIL_SELECTOR);
1945
+ if (found === null) {
1946
+ setRail(null);
1947
+ return;
1948
+ }
1949
+ const owner = claimedRails.get(found);
1950
+ if (owner !== void 0 && owner !== claimToken) {
1951
+ setRail(null);
1952
+ return;
1953
+ }
1954
+ claimedRails.set(found, claimToken);
1955
+ setRail(found);
1956
+ };
1957
+ check();
1958
+ const timer = window.setInterval(check, RAIL_POLL_MS);
1959
+ return () => {
1960
+ window.clearInterval(timer);
1961
+ if (railRef.current !== null) claimedRails.delete(railRef.current);
1962
+ };
1963
+ }, [claimToken]);
1964
+ const navigate = react.default.useCallback((item) => {
1965
+ if (item.anchorKey === void 0) return;
1966
+ const jump = jumpToAnchorRef.current;
1967
+ if (jump === void 0) return;
1968
+ jump(item.anchorKey).catch(() => {});
1969
+ }, []);
1970
+ react.default.useEffect(() => {
1971
+ if (!enabled || rail === null) return;
1972
+ const onClick = (event) => {
1973
+ if (rail.querySelector("[aria-busy=\"true\"]") !== null) return;
1974
+ const index = indexForEvent(rail, event);
1975
+ const item = ladderRef.current[index];
1976
+ if (item === void 0 || item.anchorKey === void 0) return;
1977
+ event.preventDefault();
1978
+ event.stopImmediatePropagation();
1979
+ navigate(item);
1980
+ };
1981
+ rail.addEventListener("click", onClick, true);
1982
+ return () => {
1983
+ rail.removeEventListener("click", onClick, true);
1984
+ };
1985
+ }, [
1986
+ enabled,
1987
+ rail,
1988
+ navigate
1989
+ ]);
1990
+ return null;
1991
+ }
1992
+ //#endregion
1993
+ //#region src/client/timeline/jump.ts
1994
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1995
+ /** How long one jump may keep paging older history before giving up. */
1996
+ const JUMP_PAGE_DEADLINE_MS = 3e4;
1997
+ /** How long to wait for the chat view to commit the target row. */
1998
+ const JUMP_ROW_WAIT_MS = 3e3;
1999
+ /** Settle between consecutive pages so a resolver without loadingOlder cannot hot-spin the loop. */
2000
+ const PAGE_COOLDOWN_MS = 50;
2001
+ /** Distance from the scrollport top the official ChatView lands a jumped row at. */
2002
+ const OFFICIAL_LANDING_INSET_PX = 24;
2003
+ /** Distances under this snap instead of animating. */
2004
+ const MIN_GLIDE_DISTANCE_PX = 8;
2005
+ /** Glide duration floor / cap — a far-boundary jump stays a tangible slide. */
2006
+ const MIN_GLIDE_MS = 280;
2007
+ const MAX_GLIDE_MS = 900;
2008
+ /** Every mounted conversation scrollport (multi-column layouts have several). */
2009
+ function conversationScrollports() {
2010
+ return Array.from(document.querySelectorAll("[data-conversation-scroll]"));
2011
+ }
2012
+ /**
2013
+ * Whether the target chat row is committed in ANY mounted conversation
2014
+ * scrollport. Kernel generations disagree on where the node table lives
2015
+ * (0.1.1: `SessionSnapshot.chat.nodes`; 0.1.2+: the `useChat` ChatSnapshot
2016
+ * node store, unreachable from `session.getSnapshot()` — its SessionSnapshot
2017
+ * faces no node table at all), but the rendered DOM is the ground truth both
2018
+ * share, so a jump probes it FIRST before deciding whether paging is needed.
2019
+ * @param anchorKey - chat anchor key of the target row.
2020
+ */
2021
+ function rowRenderedInDom(anchorKey) {
2022
+ const selector = `[data-chat-anchor-key="${CSS.escape(anchorKey)}"]`;
2023
+ for (const scrollport of conversationScrollports()) if (scrollport.querySelector(selector) !== null) return true;
2024
+ return false;
2025
+ }
2026
+ /**
2027
+ * Poll the conversation DOM until the target row is committed. The session
2028
+ * store updates BEFORE React renders the prepended page, so the old one-shot
2029
+ * query right after loadOlder() resolved could miss the row and silently
2030
+ * give up — the reported "click does nothing until the conversation is
2031
+ * nudged (scroll/click)" behavior.
2032
+ * @param key - chat anchor key to look for.
2033
+ * @param timeoutMs - polling budget.
2034
+ */
2035
+ async function waitForChatRow(key, timeoutMs) {
2036
+ const selector = `[data-chat-anchor-key="${CSS.escape(key)}"]`;
2037
+ const deadline = Date.now() + timeoutMs;
2038
+ for (;;) {
2039
+ for (const scrollport of conversationScrollports()) {
2040
+ const row = scrollport.querySelector(selector);
2041
+ if (row !== null) return row;
2042
+ }
2043
+ if (Date.now() >= deadline) return null;
2044
+ await delay(40);
2045
+ }
2046
+ }
2047
+ /** Maximum scrollable offset of a conversation scrollport. */
2048
+ function scrollFloorOf(el) {
2049
+ return Math.max(0, el.scrollHeight - el.clientHeight);
2050
+ }
2051
+ /**
2052
+ * Destination scrollTop that centers `row` inside `el` — the same geometry
2053
+ * as scrollIntoView({ block: 'center' }), computed directly so the glide owns
2054
+ * the scroll position instead of the browser.
2055
+ * @param el - the conversation scrollport.
2056
+ * @param row - the target chat row.
2057
+ */
2058
+ function centeredScrollTopFor(el, row) {
2059
+ const floor = scrollFloorOf(el);
2060
+ const target = el.scrollTop + (row.getBoundingClientRect().top - el.getBoundingClientRect().top) - (el.clientHeight - row.offsetHeight) / 2;
2061
+ if (!Number.isFinite(target)) return el.scrollTop;
2062
+ return Math.max(0, Math.min(floor, target));
2063
+ }
2064
+ /**
2065
+ * Destination scrollTop the official ChatView uses for a navigation jump:
2066
+ * the row's top minus a 24px inset. Kept as a named resolver so the enhancer
2067
+ * lands exactly where the official would — only animated instead of assigned.
2068
+ * @param el - the conversation scrollport.
2069
+ * @param row - the target chat row.
2070
+ */
2071
+ function officialTargetTopFor(el, row) {
2072
+ const floor = scrollFloorOf(el);
2073
+ const target = el.scrollTop + (row.getBoundingClientRect().top - el.getBoundingClientRect().top) - OFFICIAL_LANDING_INSET_PX;
2074
+ if (!Number.isFinite(target)) return el.scrollTop;
2075
+ return Math.max(0, Math.min(floor, target));
2076
+ }
2077
+ /**
2078
+ * Glide duration for a distance: longer slides take longer, bounded to
2079
+ * [MIN_GLIDE_MS, MAX_GLIDE_MS] (a deep-history jump reads as a deliberate
2080
+ * glide, not a sluggish race).
2081
+ * @param distance - px to travel.
2169
2082
  */
2170
- const OFFICIAL_RAIL_SELECTOR = "[data-conversation-scroll] nav[style*=\"--turn-natural-height\"]";
2083
+ function glideDurationFor(distance) {
2084
+ if (!Number.isFinite(distance)) return MIN_GLIDE_MS;
2085
+ return Math.round(Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, 240 + Math.abs(distance) * .14)));
2086
+ }
2171
2087
  /**
2172
- * Rails claimed by an enhancer instance, keyed by the owning instance's
2173
- * claim token. One session-scoped enhancer mounts per session; without a
2174
- * claim two instances would both attach capture listeners to the FIRST rail
2175
- * in the document (multi-column layouts) and one of them — not necessarily
2176
- * the one owning the reader's column — would win the click. The token
2177
- * distinguishes "already mine" (the poll keeps it) from "someone else's"
2178
- * (this instance must stand down). Weak: a replaced rail element disappears
2179
- * as soon as the browser collects it, so remounts need no bookkeeping.
2088
+ * Detach the host's bottom-follow state when the reader currently sits inside
2089
+ * its follow zone: assign the scroll position just outside the boundary. That
2090
+ * one synchronous scroll event settles at-bottom to false (moved-by-reader +
2091
+ * outside the zone) so a tip-moved re-render cannot yank the glide back to
2092
+ * the floor. No-op when already above the zone or when the assignment cannot
2093
+ * move (content shorter than the viewport).
2094
+ * @param el - the conversation scrollport.
2095
+ * @param followZonePx - the host's own follow threshold.
2180
2096
  */
2181
- const claimedRails = /* @__PURE__ */ new WeakMap();
2182
- /** How often the official rail is looked for (it mounts with the chat view). */
2183
- const RAIL_POLL_MS = 400;
2097
+ function detachBottomFollow(el, followZonePx = 24) {
2098
+ const floor = scrollFloorOf(el);
2099
+ if (el.scrollTop < floor - followZonePx) return false;
2100
+ const detached = Math.max(0, floor - followZonePx - 1);
2101
+ if (Math.abs(el.scrollTop - detached) < 1) return false;
2102
+ el.scrollTop = detached;
2103
+ return true;
2104
+ }
2184
2105
  /**
2185
- * The enhancer: no visual output, only the behaviour fixes the running rail
2186
- * generation lacks.
2187
- * @param props - dock slot props plus the enabled gate.
2188
- * @returns null (always).
2106
+ * Wait until a detach assignment's scroll event has been dispatched AND
2107
+ * consumed by the host's scroll handler: scroll events fire on a later frame
2108
+ * than the assignment, and a paging commit's layout effect runs before that
2109
+ * dispatch — it would re-run the follow logic while the at-bottom flag is
2110
+ * still true and drag the viewport to the growing floor, undoing the detach
2111
+ * (two rAFs + a macrotask bracket the dispatch window).
2189
2112
  */
2190
- function OfficialTimelineEnhancer(props) {
2191
- const { sessionId, sessionsService, useChat, useProjection, enabled } = props;
2192
- const ladder = mergeRailItems(normalizeNavigationItems(useChat === void 0 ? void 0 : useChat((snapshot) => {
2193
- const nav = snapshot?.navigation;
2194
- return typeof nav?.items === "function" ? nav.items() : void 0;
2195
- })), useProjection === void 0 ? void 0 : useProjection("turnOutline"));
2196
- const [rail, setRail] = react.default.useState(null);
2197
- const ladderRef = react.default.useRef(ladder);
2198
- ladderRef.current = ladder;
2199
- const railRef = react.default.useRef(rail);
2200
- railRef.current = rail;
2201
- const claimToken = react.default.useMemo(() => Symbol("dsh-bg-timeline-claim"), []);
2202
- react.default.useEffect(() => {
2203
- const check = () => {
2204
- const found = document.querySelector(OFFICIAL_RAIL_SELECTOR);
2205
- if (found === null) {
2206
- setRail(null);
2207
- return;
2208
- }
2209
- const owner = claimedRails.get(found);
2210
- if (owner !== void 0 && owner !== claimToken) {
2211
- setRail(null);
2212
- return;
2213
- }
2214
- claimedRails.set(found, claimToken);
2215
- setRail(found);
2216
- };
2217
- check();
2218
- const timer = window.setInterval(check, RAIL_POLL_MS);
2219
- return () => {
2220
- window.clearInterval(timer);
2221
- if (railRef.current !== null) claimedRails.delete(railRef.current);
2113
+ function settleScrollEvents() {
2114
+ if (typeof requestAnimationFrame !== "function") return Promise.resolve();
2115
+ return new Promise((resolve) => {
2116
+ requestAnimationFrame(() => requestAnimationFrame(() => window.setTimeout(resolve, 0)));
2117
+ });
2118
+ }
2119
+ /**
2120
+ * Animate el.scrollTop toward target with an ease-in-out cubic profile.
2121
+ * Wheel / touch / keyboard-scroll input cancels the glide so the reader
2122
+ * takes over immediately. Resolves true when the glide completed, false
2123
+ * when the reader cancelled it.
2124
+ * @param el - the conversation scrollport.
2125
+ * @param target - destination scrollTop.
2126
+ * @param durationMs - animation budget.
2127
+ */
2128
+ function animateScrollTop(el, target, durationMs) {
2129
+ return new Promise((resolve) => {
2130
+ const start = el.scrollTop;
2131
+ const delta = target - start;
2132
+ let done = false;
2133
+ let frame = 0;
2134
+ const finish = (completed) => {
2135
+ if (done) return;
2136
+ done = true;
2137
+ el.removeEventListener("wheel", onWheel);
2138
+ el.removeEventListener("touchstart", onTouch);
2139
+ window.removeEventListener("keydown", onKey);
2140
+ if (frame !== 0) cancelAnimationFrame(frame);
2141
+ resolve(completed);
2222
2142
  };
2223
- }, [claimToken]);
2224
- const navigate = react.default.useCallback((item) => {
2225
- if (item.anchorKey === void 0) return;
2226
- if (sessionsService === void 0 || sessionId === void 0) return;
2227
- const scrollport = rail === null ? null : rail.closest("[data-conversation-scroll]");
2228
- jumpToMessage(sessionsService, sessionId, item.anchorKey, {
2229
- followZonePx: 24,
2230
- targetTop: officialTargetTopFor,
2231
- scrollport
2232
- }).catch(() => {});
2233
- }, [
2234
- sessionsService,
2235
- sessionId,
2236
- rail
2237
- ]);
2238
- react.default.useEffect(() => {
2239
- if (!enabled || rail === null) return;
2240
- const onClick = (event) => {
2241
- if (rail.querySelector("[aria-busy=\"true\"]") !== null) return;
2242
- const index = indexForEvent(rail, event);
2243
- const item = ladderRef.current[index];
2244
- if (item === void 0 || item.anchorKey === void 0) return;
2245
- event.preventDefault();
2246
- event.stopImmediatePropagation();
2247
- navigate(item);
2143
+ const onWheel = () => finish(false);
2144
+ const onTouch = () => finish(false);
2145
+ const onKey = (e) => {
2146
+ if ([
2147
+ "ArrowUp",
2148
+ "ArrowDown",
2149
+ "PageUp",
2150
+ "PageDown",
2151
+ "Home",
2152
+ "End",
2153
+ " "
2154
+ ].includes(e.key)) finish(false);
2248
2155
  };
2249
- rail.addEventListener("click", onClick, true);
2250
- return () => {
2251
- rail.removeEventListener("click", onClick, true);
2156
+ el.addEventListener("wheel", onWheel, { passive: true });
2157
+ el.addEventListener("touchstart", onTouch, { passive: true });
2158
+ window.addEventListener("keydown", onKey);
2159
+ if (Math.abs(delta) < .5 || durationMs <= 0) {
2160
+ el.scrollTop = target;
2161
+ finish(true);
2162
+ return;
2163
+ }
2164
+ const t0 = performance.now();
2165
+ const ease = (x) => x < .5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
2166
+ const step = (now) => {
2167
+ if (done) return;
2168
+ const t = Math.min(1, (now - t0) / durationMs);
2169
+ el.scrollTop = start + delta * ease(t);
2170
+ if (t < 1) frame = requestAnimationFrame(step);
2171
+ else finish(true);
2252
2172
  };
2253
- }, [
2254
- enabled,
2255
- rail,
2256
- navigate
2257
- ]);
2258
- return null;
2173
+ frame = requestAnimationFrame(step);
2174
+ });
2175
+ }
2176
+ /**
2177
+ * Ensure the target node is loaded, wait for the chat view to commit its row
2178
+ * into the DOM, then glide it into view. Paging uses a time budget instead
2179
+ * of a fixed iteration guard (each in-flight page burned part of that guard,
2180
+ * so slow history could exhaust it and silently fail).
2181
+ *
2182
+ * @param sessionsService - the runtime sessions service.
2183
+ * @param sessionId - the session owning the target row.
2184
+ * @param anchorKey - chat anchor key of the target row.
2185
+ * @param options - follow zone, landing resolver, paging scopes and budgets.
2186
+ * @returns whether the row was reached (false when it never rendered or the
2187
+ * reader took the scrollport back mid-glide).
2188
+ */
2189
+ async function jumpToMessage(sessionsService, sessionId, anchorKey, options = {}) {
2190
+ const session = sessionsService.binding(sessionId)?.session;
2191
+ if (session === void 0) return false;
2192
+ const followZonePx = options.followZonePx ?? 24;
2193
+ const resolveTarget = options.targetTop ?? centeredScrollTopFor;
2194
+ const pageDeadlineMs = options.pageDeadlineMs ?? JUMP_PAGE_DEADLINE_MS;
2195
+ const rowWaitMs = options.rowWaitMs ?? JUMP_ROW_WAIT_MS;
2196
+ const ports = options.scrollport != null ? [options.scrollport] : conversationScrollports();
2197
+ let detachedAny = false;
2198
+ for (const el of ports) if (detachBottomFollow(el, followZonePx)) {
2199
+ el.dispatchEvent(new Event("scroll"));
2200
+ detachedAny = true;
2201
+ }
2202
+ if (detachedAny) await settleScrollEvents();
2203
+ const deadline = Date.now() + pageDeadlineMs;
2204
+ for (;;) {
2205
+ if (rowRenderedInDom(anchorKey)) break;
2206
+ const snapshot = session.getSnapshot();
2207
+ if (snapshot?.hasMore !== true) break;
2208
+ if (Date.now() >= deadline) break;
2209
+ if (snapshot.loadingOlder === true) {
2210
+ await delay(50);
2211
+ continue;
2212
+ }
2213
+ if (snapshot.openState !== void 0 && snapshot.openState !== "open") {
2214
+ await delay(100);
2215
+ continue;
2216
+ }
2217
+ try {
2218
+ await session.loadOlder();
2219
+ } catch (error) {
2220
+ console.warn("[deepseek-harness-background] timeline jump paging failed:", error);
2221
+ break;
2222
+ }
2223
+ await delay(PAGE_COOLDOWN_MS);
2224
+ }
2225
+ const row = await waitForChatRow(anchorKey, rowWaitMs);
2226
+ if (row === null) {
2227
+ console.warn(`[deepseek-harness-background] timeline jump target never rendered: ${anchorKey}`);
2228
+ return false;
2229
+ }
2230
+ const reducedMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2231
+ const scroller = row.closest("[data-conversation-scroll]");
2232
+ if (scroller === null) {
2233
+ row.scrollIntoView({
2234
+ behavior: "auto",
2235
+ block: "center"
2236
+ });
2237
+ if (!reducedMotion) row.scrollIntoView({
2238
+ behavior: "smooth",
2239
+ block: "center"
2240
+ });
2241
+ return true;
2242
+ }
2243
+ const target = resolveTarget(scroller, row);
2244
+ if (reducedMotion || Math.abs(target - scroller.scrollTop) < MIN_GLIDE_DISTANCE_PX) {
2245
+ scroller.scrollTop = target;
2246
+ return true;
2247
+ }
2248
+ if (detachBottomFollow(scroller, followZonePx)) {
2249
+ scroller.dispatchEvent(new Event("scroll"));
2250
+ await settleScrollEvents();
2251
+ }
2252
+ return await animateScrollTop(scroller, target, glideDurationFor(Math.abs(target - scroller.scrollTop)));
2259
2253
  }
2260
2254
  //#endregion
2261
2255
  //#region src/client/timeline/index.tsx
@@ -2361,7 +2355,14 @@ window.__ModuleLoader__.load({
2361
2355
  id: "deepseek-harness-background.timeline",
2362
2356
  order: 45,
2363
2357
  locale: "ui-background",
2364
- inject: () => ({ sessionsService: ctx.sessions })
2358
+ inject: (sessionId) => ({ jumpToAnchor: (anchorKey) => {
2359
+ const scrollport = document.querySelector("[data-conversation-scroll] nav[style*=\"--turn-natural-height\"]")?.closest("[data-conversation-scroll]") ?? null;
2360
+ return jumpToMessage(ctx.sessions, sessionId, anchorKey, {
2361
+ followZonePx: 24,
2362
+ targetTop: officialTargetTopFor,
2363
+ scrollport
2364
+ });
2365
+ } })
2365
2366
  }, TimelineBridge));
2366
2367
  ctx.effect(() => () => {
2367
2368
  unsubscribe();