deepseek-harness-background 0.5.1 → 0.5.3

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
@@ -29,8 +29,6 @@ window.__ModuleLoader__.load({
29
29
  let react = require("react");
30
30
  react = __toESM(react, 1);
31
31
  let react_jsx_runtime = require("react/jsx-runtime");
32
- let react_dom = require("react-dom");
33
- react_dom = __toESM(react_dom, 1);
34
32
  //#region src/client/background-css.ts
35
33
  /**
36
34
  * Background CSS — the stylesheet injected once into the document when the
@@ -65,7 +63,7 @@ window.__ModuleLoader__.load({
65
63
  * dock family (agent task strips: TodoPanel / GoalBar / QueueDock) and its
66
64
  * takeover panels (approval, question, plan review), the subagent lineage
67
65
  * popover, the home hero "preview" badge, the sidebar build badge, and the
68
- * timeline rail (timeline/legacy-rail-css.ts + the official-rail rules below).
66
+ * timeline rail (the official-rail rules below).
69
67
  * Every glassed surface shares ONE recipe:
70
68
  * fill from the painter's --dsw-specific-input-major token (same alpha
71
69
  * curve + theme dimming), the full shared blur/saturate/brightness chain
@@ -132,6 +130,9 @@ window.__ModuleLoader__.load({
132
130
  slider returns these surfaces to the exact official paints. */
133
131
  body[data-dsh-bg-glass] [data-composer-card],
134
132
  body[data-dsh-bg-glass] [class*="_bubble"]:not([role="tooltip"]),
133
+ /* The read_image toolview (dsh >= 0.1.2-rc.1 ToolRow) collapses its result
134
+ into an image body card; glass it like the other content blocks. */
135
+ body[data-dsh-bg-glass] [class*="_imageBody"],
135
136
  body[data-dsh-bg-glass] .md-code-block,
136
137
  body[data-dsh-bg-glass] [data-terminal],
137
138
  body[data-dsh-bg-glass] [data-diff],
@@ -322,12 +323,6 @@ window.__ModuleLoader__.load({
322
323
  const DEFAULT_SCRIM = .25;
323
324
  /** Default panel opacity (0..1); 1 = fully opaque official surfaces, 0 = fully transparent glass. */
324
325
  const DEFAULT_PANEL_OPACITY = .15;
325
- /**
326
- * Session-projection key this plugin owns in the client-visible value map —
327
- * the host fold registers it (src/projection.ts) and the rail reads it
328
- * (src/client/timeline/source.ts); one shared constant so the two can never drift.
329
- */
330
- const TIMELINE_PROJECTION_KEY = "bgTimeline";
331
326
  /** Default background rendering mode. */
332
327
  const DEFAULT_FIT = "cover";
333
328
  /** Image positioning modes. `cover` fills the frame (cropping as needed); `contain` fits the whole image inside the frame (letterboxing). */
@@ -965,13 +960,8 @@ window.__ModuleLoader__.load({
965
960
  "background.clear": "清除背景",
966
961
  "background.uploadFailed": "上传失败,请重试",
967
962
  "background.saveFailed": "保存失败,请重试",
968
- "background.timeline": "会话时间线",
969
- "background.timelineHint": "右侧轮次导航轨,悬停预览该轮问题与直达更早历史",
970
963
  "background.timelineEnhance": "会话时间线增强",
971
- "background.timelineEnhanceHint": "为官方会话时间线补上平滑跳转,并可直达需要“加载更多”的更早轮次",
972
- "timeline.railLabel": "轮次导航",
973
- "timeline.noText": "(无文本内容)",
974
- "timeline.jump": "跳转到第 {turn} 轮"
964
+ "background.timelineEnhanceNarrowHint": "为官方会话时间线的已加载轮次补上平滑跳转"
975
965
  };
976
966
  /** English copy. */
977
967
  const en = {
@@ -999,13 +989,8 @@ window.__ModuleLoader__.load({
999
989
  "background.clear": "Clear background",
1000
990
  "background.uploadFailed": "Upload failed, try again",
1001
991
  "background.saveFailed": "Save failed, try again",
1002
- "background.timeline": "Conversation timeline",
1003
- "background.timelineHint": "Turn rail at the right edge; hover to preview a turn and reach earlier history",
1004
992
  "background.timelineEnhance": "Conversation timeline enhancement",
1005
- "background.timelineEnhanceHint": "Adds smooth jumps and direct reach into turns that still need “load earlier”",
1006
- "timeline.railLabel": "Turn navigation",
1007
- "timeline.noText": "(no text)",
1008
- "timeline.jump": "Jump to turn {turn}"
993
+ "background.timelineEnhanceNarrowHint": "Adds smooth jumps for the rail's loaded turns"
1009
994
  };
1010
995
  //#endregion
1011
996
  //#region node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
@@ -1129,31 +1114,6 @@ window.__ModuleLoader__.load({
1129
1114
  /** Shared client for the painter and the settings row. */
1130
1115
  const settingsClient = new SettingsClient();
1131
1116
  //#endregion
1132
- //#region src/client/timeline/mode-store.ts
1133
- let mode = "legacy";
1134
- const listeners = /* @__PURE__ */ new Set();
1135
- /** @returns the currently detected mode. */
1136
- function timelineMode() {
1137
- return mode;
1138
- }
1139
- /** Observe mode changes. @returns the disposer. */
1140
- function subscribeTimelineMode(listener) {
1141
- listeners.add(listener);
1142
- return () => {
1143
- listeners.delete(listener);
1144
- };
1145
- }
1146
- /**
1147
- * Publish the detected mode (called by the session-scoped dock entry).
1148
- * No-ops when the mode is unchanged, so a re-render cannot notify.
1149
- * @param next - the mode to publish.
1150
- */
1151
- function reportTimelineMode(next) {
1152
- if (mode === next) return;
1153
- mode = next;
1154
- for (const listener of listeners) listener();
1155
- }
1156
- //#endregion
1157
1117
  //#region \0dsh-css:D:\Project\deepseek-harness-background\src\client\SettingsRow.module.css.mjs
1158
1118
  const css = ".-ozqdq_row{border-bottom:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:14px;padding:16px 0;display:flex}.-ozqdq_header{flex-direction:column;gap:4px;display:flex}.-ozqdq_titleRow{justify-content:space-between;align-items:center;gap:12px;min-height:22px;display:flex}.-ozqdq_clearBtn{border-radius:12px;flex:none;height:24px;padding:0 10px;font-size:12px;line-height:24px}.-ozqdq_title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}.-ozqdq_desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}.-ozqdq_preview{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:12px;height:132px;position:relative;overflow:hidden}.-ozqdq_previewImg{object-fit:var(--bg-object-fit,cover);width:100%;height:100%;opacity:var(--bg-opacity,1);filter:blur(var(--bg-wallpaper-blur,0px));transform:scale(var(--bg-wallpaper-scale,1));display:block;position:absolute;inset:0}.-ozqdq_previewScrim{background:rgba(255, 255, 255, var(--bg-scrim,.25));position:absolute;inset:0}body[data-ds-dark-theme] .-ozqdq_previewScrim{background:rgba(0, 0, 0, var(--bg-scrim,.25))}.-ozqdq_previewGlass{background:var(--bg-preview-glass,#ffffff24);width:44%;height:46px;backdrop-filter:blur(var(--bg-glass-blur,16px)) saturate(var(--bg-glass-saturate,1.42)) brightness(var(--bg-glass-brightness,1));border:1px solid #ffffff38;border-radius:10px;justify-content:center;align-items:center;display:flex;position:absolute;bottom:16px;left:16px;box-shadow:inset 0 1px #ffffff4d}.-ozqdq_previewGlassText{color:#0f172ac7;text-shadow:0 1px 2px #fff6;white-space:nowrap;font-size:11px;line-height:1}body[data-ds-dark-theme] .-ozqdq_previewGlassText{color:#ffffffd9;text-shadow:0 1px 2px #00000059}.-ozqdq_previewEmpty{color:var(--dsw-alias-label-tertiary);justify-content:center;align-items:center;font-size:12px;line-height:1.5;display:flex;position:absolute;inset:0}.-ozqdq_section{flex-direction:column;gap:8px;display:flex}.-ozqdq_sectionLabel{color:var(--dsw-alias-label-tertiary);letter-spacing:.01em;font-size:12px;font-weight:500;line-height:18px}.-ozqdq_sourceRow{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.-ozqdq_urlRow{align-items:center;gap:8px;display:flex}.-ozqdq_urlInput{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);min-width:0;height:32px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;flex:1;padding:0 10px;font-size:13px;line-height:1.5}.-ozqdq_urlInput::placeholder{color:var(--dsw-alias-label-dimmed)}.-ozqdq_urlInput:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.-ozqdq_hint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.-ozqdq_enabledBadge{white-space:nowrap;background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-primary);border-radius:999px;align-items:center;gap:4px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;display:inline-flex}.-ozqdq_btn{border:1px solid var(--dsw-alias-border-l2);height:28px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;background:0 0;border-radius:14px;justify-content:center;align-items:center;padding:0 12px;font-size:12px;line-height:18px;transition:background-color .15s,border-color .15s,color .15s;display:inline-flex}.-ozqdq_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.-ozqdq_btn:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.-ozqdq_btn:disabled{opacity:.45;cursor:default}.-ozqdq_btnPrimary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:#0000}.-ozqdq_btnPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.-ozqdq_btnDanger{color:var(--dsw-alias-label-error)}.-ozqdq_btnDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.-ozqdq_sliderRow{align-items:center;gap:10px;display:flex}.-ozqdq_sliderLabel{color:var(--dsw-alias-label-primary);flex:0 0 96px;font-size:13px;line-height:1.5}.-ozqdq_slider{-webkit-appearance:none;appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:18px;margin:0}.-ozqdq_slider::-webkit-slider-runnable-track{background:linear-gradient(90deg, var(--dsw-alias-brand-primary) var(--bg-fill,0%), var(--dsw-alias-border-l3) var(--bg-fill,0%));border-radius:2px;height:4px}.-ozqdq_slider::-moz-range-track{background:var(--dsw-alias-border-l3);border-radius:2px;height:4px}.-ozqdq_slider::-moz-range-progress{background:var(--dsw-alias-brand-primary);border-radius:2px;height:4px}.-ozqdq_slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;border:2px solid var(--dsw-alias-brand-primary);width:14px;height:14px;box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent), 0 1px 3px #00000047;background:#fff;border-radius:50%;margin-top:-5px}.-ozqdq_slider::-moz-range-thumb{border:2px solid var(--dsw-alias-brand-primary);width:14px;height:14px;box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent), 0 1px 3px #00000047;background:#fff;border-radius:50%}.-ozqdq_slider:hover::-webkit-slider-thumb{box-shadow:0 0 0 5px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent), 0 1px 4px #0000004d}.-ozqdq_slider:hover::-moz-range-thumb{box-shadow:0 0 0 5px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent), 0 1px 4px #0000004d}.-ozqdq_slider:active::-webkit-slider-thumb{box-shadow:0 0 0 7px color-mix(in srgb, var(--dsw-alias-brand-primary) 22%, transparent), 0 1px 5px #00000052;transform:scale(1.12)}.-ozqdq_slider:active::-moz-range-thumb{box-shadow:0 0 0 7px color-mix(in srgb, var(--dsw-alias-brand-primary) 22%, transparent), 0 1px 5px #00000052;transform:scale(1.12)}.-ozqdq_slider:focus-visible{outline:none}.-ozqdq_slider:focus-visible::-webkit-slider-thumb{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.-ozqdq_slider:focus-visible::-moz-range-thumb{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.-ozqdq_sliderValue{text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);flex:0 0 52px;font-size:13px;line-height:1.5}.-ozqdq_segRow{align-items:center;gap:10px;display:flex}.-ozqdq_segLabel{color:var(--dsw-alias-label-primary);flex:0 0 96px;font-size:13px;line-height:1.5}.-ozqdq_segGroup{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:9px;flex:1;gap:2px;min-width:0;padding:2px;display:inline-flex}.-ozqdq_segBtn{height:24px;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:7px;flex:1;padding:0 16px;font-size:12px;line-height:24px}.-ozqdq_segBtn:hover:not(.-ozqdq_segActive){color:var(--dsw-alias-label-primary)}.-ozqdq_segActive{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-weight:500}.-ozqdq_error{color:var(--dsw-alias-label-error);font-size:12px;line-height:1.5}.-ozqdq_clearRow{align-items:center;gap:10px;display:flex}.-ozqdq_switch{background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:11px;flex:none;width:40px;height:22px;padding:0;transition:background-color .15s;position:relative}.-ozqdq_switch[aria-checked=true]{background:var(--dsw-alias-brand-primary)}.-ozqdq_switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.-ozqdq_switchKnob{background:#fff;border-radius:50%;width:16px;height:16px;transition:transform .15s;position:absolute;top:3px;left:3px;box-shadow:0 1px 3px #00000047}.-ozqdq_switch[aria-checked=true] .-ozqdq_switchKnob{transform:translate(18px)}@media (prefers-reduced-motion:reduce){.-ozqdq_btn,.-ozqdq_switch,.-ozqdq_switchKnob{transition:none}}";
1159
1119
  const tagId = "deepseek-harness-background/SettingsRow.module.css";
@@ -1165,41 +1125,41 @@ window.__ModuleLoader__.load({
1165
1125
  document.head.appendChild(tag);
1166
1126
  }
1167
1127
  var SettingsRow_module_css_default = {
1168
- "sliderValue": "-ozqdq_sliderValue",
1169
- "btnPrimary": "-ozqdq_btnPrimary",
1170
- "desc": "-ozqdq_desc",
1171
- "previewScrim": "-ozqdq_previewScrim",
1172
- "section": "-ozqdq_section",
1173
- "sliderLabel": "-ozqdq_sliderLabel",
1174
- "title": "-ozqdq_title",
1175
- "previewGlass": "-ozqdq_previewGlass",
1176
- "urlInput": "-ozqdq_urlInput",
1177
- "segGroup": "-ozqdq_segGroup",
1178
- "segRow": "-ozqdq_segRow",
1179
- "btn": "-ozqdq_btn",
1180
- "clearRow": "-ozqdq_clearRow",
1181
- "previewImg": "-ozqdq_previewImg",
1128
+ "clearBtn": "-ozqdq_clearBtn",
1182
1129
  "slider": "-ozqdq_slider",
1183
- "btnDanger": "-ozqdq_btnDanger",
1184
1130
  "sourceRow": "-ozqdq_sourceRow",
1185
- "previewEmpty": "-ozqdq_previewEmpty",
1186
- "error": "-ozqdq_error",
1131
+ "preview": "-ozqdq_preview",
1187
1132
  "switch": "-ozqdq_switch",
1133
+ "segGroup": "-ozqdq_segGroup",
1134
+ "btnDanger": "-ozqdq_btnDanger",
1135
+ "previewEmpty": "-ozqdq_previewEmpty",
1136
+ "titleRow": "-ozqdq_titleRow",
1188
1137
  "sliderRow": "-ozqdq_sliderRow",
1189
- "segLabel": "-ozqdq_segLabel",
1190
- "sectionLabel": "-ozqdq_sectionLabel",
1191
- "header": "-ozqdq_header",
1192
- "clearBtn": "-ozqdq_clearBtn",
1193
- "preview": "-ozqdq_preview",
1194
- "previewGlassText": "-ozqdq_previewGlassText",
1195
- "enabledBadge": "-ozqdq_enabledBadge",
1138
+ "sliderValue": "-ozqdq_sliderValue",
1196
1139
  "segBtn": "-ozqdq_segBtn",
1197
- "segActive": "-ozqdq_segActive",
1198
- "titleRow": "-ozqdq_titleRow",
1199
- "switchKnob": "-ozqdq_switchKnob",
1140
+ "previewGlass": "-ozqdq_previewGlass",
1200
1141
  "row": "-ozqdq_row",
1142
+ "error": "-ozqdq_error",
1143
+ "previewScrim": "-ozqdq_previewScrim",
1144
+ "switchKnob": "-ozqdq_switchKnob",
1145
+ "urlInput": "-ozqdq_urlInput",
1146
+ "btnPrimary": "-ozqdq_btnPrimary",
1147
+ "segActive": "-ozqdq_segActive",
1148
+ "segLabel": "-ozqdq_segLabel",
1201
1149
  "urlRow": "-ozqdq_urlRow",
1202
- "hint": "-ozqdq_hint"
1150
+ "previewGlassText": "-ozqdq_previewGlassText",
1151
+ "sectionLabel": "-ozqdq_sectionLabel",
1152
+ "enabledBadge": "-ozqdq_enabledBadge",
1153
+ "segRow": "-ozqdq_segRow",
1154
+ "previewImg": "-ozqdq_previewImg",
1155
+ "btn": "-ozqdq_btn",
1156
+ "sliderLabel": "-ozqdq_sliderLabel",
1157
+ "header": "-ozqdq_header",
1158
+ "clearRow": "-ozqdq_clearRow",
1159
+ "desc": "-ozqdq_desc",
1160
+ "section": "-ozqdq_section",
1161
+ "hint": "-ozqdq_hint",
1162
+ "title": "-ozqdq_title"
1203
1163
  };
1204
1164
  //#endregion
1205
1165
  //#region src/client/SettingsRow.tsx
@@ -1365,9 +1325,8 @@ window.__ModuleLoader__.load({
1365
1325
  */
1366
1326
  function BackgroundSettingsRow({ t }) {
1367
1327
  const snapshot = (0, react.useSyncExternalStore)(settingsClient.subscribe, readSnapshot);
1368
- const navMode = (0, react.useSyncExternalStore)(subscribeTimelineMode, timelineMode);
1369
- const timelineLabelKey = navMode === "enhance" ? "background.timelineEnhance" : "background.timeline";
1370
- const timelineHintKey = navMode === "enhance" ? "background.timelineEnhanceHint" : "background.timelineHint";
1328
+ const timelineLabelKey = "background.timelineEnhance";
1329
+ const timelineHintKey = "background.timelineEnhanceNarrowHint";
1371
1330
  const [draft, setDraft] = (0, react.useState)(DEFAULTS);
1372
1331
  const [uploading, setUploading] = (0, react.useState)(false);
1373
1332
  const [urlText, setUrlText] = (0, react.useState)("");
@@ -1743,6 +1702,8 @@ window.__ModuleLoader__.load({
1743
1702
  const JUMP_PAGE_DEADLINE_MS = 3e4;
1744
1703
  /** How long to wait for the chat view to commit the target row. */
1745
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;
1746
1707
  /** Distance from the scrollport top the official ChatView lands a jumped row at. */
1747
1708
  const OFFICIAL_LANDING_INSET_PX = 24;
1748
1709
  /** Distances under this snap instead of animating. */
@@ -1750,14 +1711,6 @@ window.__ModuleLoader__.load({
1750
1711
  /** Glide duration floor / cap — a far-boundary jump stays a tangible slide. */
1751
1712
  const MIN_GLIDE_MS = 280;
1752
1713
  const MAX_GLIDE_MS = 900;
1753
- /** How many rounds one warm-up page waits for its prepend to commit. */
1754
- const WARM_PAGE_SETTLE_ROUNDS = 8;
1755
- /** Pause between those rounds. */
1756
- const WARM_PAGE_SETTLE_MS = 25;
1757
- /** Wall-clock budget for one warm-up run. */
1758
- const WARM_DEADLINE_MS = 15e3;
1759
- /** Page budget for one warm-up run. */
1760
- const WARM_MAX_PAGES = 40;
1761
1714
  /** Every mounted conversation scrollport (multi-column layouts have several). */
1762
1715
  function conversationScrollports() {
1763
1716
  return Array.from(document.querySelectorAll("[data-conversation-scroll]"));
@@ -1847,7 +1800,7 @@ window.__ModuleLoader__.load({
1847
1800
  * @param el - the conversation scrollport.
1848
1801
  * @param followZonePx - the host's own follow threshold.
1849
1802
  */
1850
- function detachBottomFollow(el, followZonePx = 25) {
1803
+ function detachBottomFollow(el, followZonePx = 24) {
1851
1804
  const floor = scrollFloorOf(el);
1852
1805
  if (el.scrollTop < floor - followZonePx) return false;
1853
1806
  const detached = Math.max(0, floor - followZonePx - 1);
@@ -1927,81 +1880,6 @@ window.__ModuleLoader__.load({
1927
1880
  });
1928
1881
  }
1929
1882
  /**
1930
- * Page older history in WITHOUT moving what the reader is looking at.
1931
- *
1932
- * The host's ChatView compensates a prepend through an internal anchor ref
1933
- * that only its own "load earlier" button and its own navigation arm. A
1934
- * plugin calling `session.loadOlder()` from the outside gets no compensation,
1935
- * so the prepended rows push the reader's content down — a visible jump.
1936
- * This restores the visual position by the growth the prepend added.
1937
- *
1938
- * A reader pinned to the floor needs none of it: the host's follow logic
1939
- * re-pins to the (growing) floor by itself, and compensating there would
1940
- * fight it and overshoot.
1941
- *
1942
- * @param session - the session handle to page.
1943
- * @param el - the conversation scrollport.
1944
- * @param followZonePx - the host's bottom-follow threshold.
1945
- * @returns whether the page actually grew the transcript (false = exhausted
1946
- * or failed, so the caller should stop paging).
1947
- */
1948
- async function compensatedLoadOlder(session, el, followZonePx = 25) {
1949
- const pinned = scrollFloorOf(el) - el.scrollTop <= followZonePx;
1950
- const heightBefore = el.scrollHeight;
1951
- try {
1952
- await session.loadOlder();
1953
- } catch (error) {
1954
- console.warn("[deepseek-harness-background] timeline history page failed:", error);
1955
- return false;
1956
- }
1957
- for (let round = 0; round < WARM_PAGE_SETTLE_ROUNDS; round += 1) {
1958
- await settleScrollEvents();
1959
- const growth = el.scrollHeight - heightBefore;
1960
- if (growth > 0) {
1961
- if (!pinned) el.scrollTop = el.scrollTop + growth;
1962
- return true;
1963
- }
1964
- if (round < 7) await delay(WARM_PAGE_SETTLE_MS);
1965
- }
1966
- return false;
1967
- }
1968
- /**
1969
- * Page older history in until the rail is full or the log is exhausted — the
1970
- * "turns behind the load-earlier button become reachable" half of the
1971
- * enhancement, shared by both frontends.
1972
- *
1973
- * Stops at `capacity` on purpose: past the rail's uncompressed tick count the
1974
- * official stylesheet squeezes every mark into the same 420px column and the
1975
- * rail degrades into a solid bar the reader cannot aim at. Filling it exactly
1976
- * to capacity is free reach; going past it is not.
1977
- *
1978
- * @param session - the session handle to page.
1979
- * @param scrollport - the conversation scrollport (compensation anchor).
1980
- * @param options - budgets, the live entry count and the rail's capacity.
1981
- */
1982
- async function warmHistory(session, scrollport, options) {
1983
- const followZonePx = options.followZonePx ?? 25;
1984
- const deadline = Date.now() + (options.deadlineMs ?? WARM_DEADLINE_MS);
1985
- const maxPages = options.maxPages ?? WARM_MAX_PAGES;
1986
- const alive = options.alive ?? (() => true);
1987
- for (let page = 0; page < maxPages; page += 1) {
1988
- if (!alive()) return;
1989
- if (options.countOf() >= options.capacity) return;
1990
- const snapshot = session.getSnapshot();
1991
- if (snapshot?.hasMore !== true) return;
1992
- if (Date.now() >= deadline) return;
1993
- if (snapshot.loadingOlder === true) {
1994
- await delay(50);
1995
- continue;
1996
- }
1997
- if (snapshot.openState !== void 0 && snapshot.openState !== "open") {
1998
- await delay(100);
1999
- continue;
2000
- }
2001
- if (!await compensatedLoadOlder(session, scrollport, followZonePx)) return;
2002
- }
2003
- }
2004
- /**
2005
1883
  * Ensure the target node is loaded, wait for the chat view to commit its row
2006
1884
  * into the DOM, then glide it into view. Paging uses a time budget instead
2007
1885
  * of a fixed iteration guard (each in-flight page burned part of that guard,
@@ -2017,7 +1895,7 @@ window.__ModuleLoader__.load({
2017
1895
  async function jumpToMessage(sessionsService, sessionId, anchorKey, options = {}) {
2018
1896
  const session = sessionsService.binding(sessionId)?.session;
2019
1897
  if (session === void 0) return false;
2020
- const followZonePx = options.followZonePx ?? 25;
1898
+ const followZonePx = options.followZonePx ?? 24;
2021
1899
  const resolveTarget = options.targetTop ?? centeredScrollTopFor;
2022
1900
  const pageDeadlineMs = options.pageDeadlineMs ?? JUMP_PAGE_DEADLINE_MS;
2023
1901
  const rowWaitMs = options.rowWaitMs ?? JUMP_ROW_WAIT_MS;
@@ -2032,7 +1910,6 @@ window.__ModuleLoader__.load({
2032
1910
  for (;;) {
2033
1911
  if (rowRenderedInDom(anchorKey)) break;
2034
1912
  const snapshot = session.getSnapshot();
2035
- if ((snapshot?.chat?.nodes?.get(anchorKey) ?? snapshot?.nodes?.get(anchorKey)) !== void 0) break;
2036
1913
  if (snapshot?.hasMore !== true) break;
2037
1914
  if (Date.now() >= deadline) break;
2038
1915
  if (snapshot.loadingOlder === true) {
@@ -2049,6 +1926,7 @@ window.__ModuleLoader__.load({
2049
1926
  console.warn("[deepseek-harness-background] timeline jump paging failed:", error);
2050
1927
  break;
2051
1928
  }
1929
+ await delay(PAGE_COOLDOWN_MS);
2052
1930
  }
2053
1931
  const row = await waitForChatRow(anchorKey, rowWaitMs);
2054
1932
  if (row === null) {
@@ -2079,10 +1957,6 @@ window.__ModuleLoader__.load({
2079
1957
  }
2080
1958
  return await animateScrollTop(scroller, target, glideDurationFor(Math.abs(target - scroller.scrollTop)));
2081
1959
  }
2082
- /** The official stylesheet's rail height cap (TurnNavigator.module.css). */
2083
- const RAIL_HEIGHT_CAP_PX = 420;
2084
- /** Band clearance the official stylesheet reserves around the rail. */
2085
- const RAIL_BAND_CLEARANCE_PX = 64;
2086
1960
  /**
2087
1961
  * The rail's own inset, read off the custom property the official component
2088
1962
  * publishes (`--turn-rail-inset`). Falls back to the official constant.
@@ -2094,70 +1968,51 @@ window.__ModuleLoader__.load({
2094
1968
  return Number.isFinite(px) && px >= 0 ? px : 6;
2095
1969
  }
2096
1970
  /**
2097
- * The tallest the rail can render. The official stylesheet clamps the rail's
2098
- * height with `min(natural, band - 64px, 420px)` and the band measurements
2099
- * are published inline on the scroll host (ConversationRoot's
2100
- * `--dsh-conversation-viewport-height` / `--dsh-composer-height`). Outside
2101
- * the official layout — the ported rail portals itself to body, and its own
2102
- * stylesheet carries the same 420px cap — only the cap applies.
2103
- *
2104
- * The rendered `clientHeight` must NOT be used here: with few loaded turns
2105
- * the height IS the natural (count-driven) height, so a measured capacity
2106
- * would equal the count and a "rail full" gate could never open — the
2107
- * warm-up fixed point that left early turns unreachable.
2108
- */
2109
- function railMaxHeightOf(rail) {
2110
- const scrollport = rail.closest("[data-conversation-scroll]");
2111
- if (scrollport !== null) {
2112
- const viewport = Number.parseFloat(scrollport.style.getPropertyValue("--dsh-conversation-viewport-height"));
2113
- const composer = Number.parseFloat(scrollport.style.getPropertyValue("--dsh-composer-height"));
2114
- if (Number.isFinite(viewport) && Number.isFinite(composer)) return Math.min(RAIL_HEIGHT_CAP_PX, Math.max(0, viewport - composer - RAIL_BAND_CLEARANCE_PX));
2115
- }
2116
- return RAIL_HEIGHT_CAP_PX;
2117
- }
2118
- /**
2119
- * How many marks the rail can show before it starts compressing them.
2120
- *
2121
- * Past this count the official stylesheet clamps the rail's height and every
2122
- * mark switches from its fixed 10px spacing to a percentage position, so the
2123
- * column degrades into a solid bar the reader cannot aim at. Filling the rail
2124
- * exactly to this capacity is therefore free extra reach; going past it is
2125
- * not.
2126
- *
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.
2127
1977
  * @param rail - the rail element (or null).
2128
1978
  */
2129
- function railCapacityOf(rail) {
2130
- if (rail === null) return 41;
2131
- const usable = railMaxHeightOf(rail) - 2 * railInsetOf(rail);
2132
- if (!Number.isFinite(usable)) return 41;
2133
- return Math.max(2, Math.floor(usable / 10) + 1);
1979
+ function isFrameRail(rail) {
1980
+ return rail !== null && rail.style.getPropertyValue("--turn-scroll-top") !== "";
2134
1981
  }
2135
1982
  /**
2136
- * Resolve the entry under a pointer Y — the official geometry verbatim
2137
- * (`itemAtPointer` in TurnNavigator.tsx), so this plugin lands on exactly the
2138
- * mark the official rail would have picked.
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.
2139
1990
  * @param count - number of entries the rail shows.
2140
1991
  * @param rail - the rail element.
2141
1992
  * @param clientY - pointer viewport Y.
2142
1993
  */
2143
- function indexAtPointer(count, rail, clientY) {
1994
+ function frameIndexAtPointer(count, rail, clientY) {
2144
1995
  const rect = rail.getBoundingClientRect();
2145
1996
  const inset = railInsetOf(rail);
2146
- const usableHeight = Math.max(1, rect.height - 2 * inset);
2147
- const ratio = Math.max(0, Math.min(1, (clientY - rect.top - inset) / usableHeight));
2148
- return Math.round(ratio * (count - 1));
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)));
2149
2001
  }
2150
2002
  /**
2151
2003
  * Resolve the gesture that produced a rail click to an entry index.
2152
2004
  *
2153
2005
  * Keyboard activation targets the focused mark button (recovered by its
2154
2006
  * position among the rail's buttons); a mouse click carries coordinates and
2155
- * resolves through the rail geometry. Returns -1 when neither applies.
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).
2156
2011
  *
2157
- * Assumption (both frontends): the mark ticks are the ONLY buttons under the
2158
- * rail — the official rail renders marks exclusively and its preview tooltip
2159
- * carries none, and the ported rail does the same. A future non-mark control
2160
- * added inside the rail would shift the button index and must revisit this.
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.
2161
2016
  *
2162
2017
  * @param rail - the rail element.
2163
2018
  * @param event - the click event.
@@ -2172,7 +2027,8 @@ window.__ModuleLoader__.load({
2172
2027
  }
2173
2028
  }
2174
2029
  const count = rail.querySelectorAll("button").length;
2175
- return count === 0 ? -1 : indexAtPointer(count, rail, event.clientY);
2030
+ if (count === 0) return -1;
2031
+ return isFrameRail(rail) ? frameIndexAtPointer(count, rail, event.clientY) : -1;
2176
2032
  }
2177
2033
  /**
2178
2034
  * Validate the official navigation array crossing the slot boundary. The
@@ -2200,753 +2056,86 @@ window.__ModuleLoader__.load({
2200
2056
  }
2201
2057
  return out;
2202
2058
  }
2203
- //#endregion
2204
- //#region src/client/timeline/source.ts
2205
- /**
2206
- * Enumerate the chat nodes of one conversation snapshot, duck-typed across
2207
- * both kernel generations:
2208
- * - dsh <= 0.1.1: `snapshot.chat.nodes` is a `Map` keyed by node key.
2209
- * - dsh >= 0.1.2: the Chat snapshot is `{ order, nodes: { values() } }` and
2210
- * the session snapshot has no `chat` field at all.
2211
- *
2212
- * Both stores expose `values()`, so one structural read covers both; the
2213
- * receiver must survive the call because these are prototype methods (the
2214
- * real Session's own methods read `this`, and so do Map's).
2215
- *
2216
- * @param snapshot - a session snapshot or a Chat snapshot (loosely typed).
2217
- * @returns the nodes, in the store's own order; empty when neither shape fits.
2218
- */
2219
- function chatNodesOf(snapshot) {
2220
- const valuesOf = (store) => {
2221
- if (store === null || typeof store !== "object") return void 0;
2222
- const values = store.values;
2223
- if (typeof values !== "function") return void 0;
2224
- let iterable;
2225
- try {
2226
- iterable = values.call(store);
2227
- } catch {
2228
- return;
2229
- }
2230
- if (iterable === null || typeof iterable !== "object") return void 0;
2231
- const out = [];
2232
- for (const node of iterable) if (node !== null && typeof node === "object") out.push(node);
2233
- return out;
2234
- };
2235
- const rec = snapshot ?? {};
2236
- return valuesOf(rec.chat?.nodes) ?? valuesOf(rec.nodes) ?? [];
2237
- }
2238
- /** Is this node part of the visible surface? Nodes without the flag are legacy. */
2239
- function isVisible(node) {
2240
- return node.visibility === void 0 || node.visibility === "visible";
2241
- }
2242
- /** Preview cap so entries stay small (one short line per row). */
2243
- const MAX_TEXT_CHARS = 80;
2244
- /** Extract preview text from a user message's ContentBlock list. */
2245
- function userTextOf(content) {
2246
- if (!Array.isArray(content)) return "";
2247
- let out = "";
2248
- for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
2249
- const text = block.text;
2250
- if (typeof text === "string") out += text;
2251
- }
2252
- return out.trim().slice(0, MAX_TEXT_CHARS);
2253
- }
2254
- /** Parse digits into a finite number (undefined otherwise). */
2255
- function parseSeqDigits(digits) {
2256
- if (digits === void 0) return void 0;
2257
- const n = Number.parseInt(digits, 10);
2258
- return Number.isFinite(n) ? n : void 0;
2259
- }
2260
- /** Extract a seq from a string-ish field ('@42', 'seq 42', '#42', bare 42). */
2261
- function seqFromStringish(value) {
2262
- if (typeof value === "number" && Number.isFinite(value)) return value;
2263
- if (typeof value !== "string") return void 0;
2264
- const m = /(?:@|seq\s*|#)?(\d+)/i.exec(value);
2265
- return m === null ? void 0 : parseSeqDigits(m[1]);
2266
- }
2267
- /**
2268
- * Extract the rewind target seq from a rewind outcome text. Multi-pattern:
2269
- * an explicit `seq 42` declaration wins, then `#42`, then the broad
2270
- * english/chinese verb forms — a single "rewound" regex silently missed real
2271
- * success texts like "已撤回 seq 42" / "Withdrawn seq 42".
2272
- * @param text - the command outcome text.
2273
- */
2274
- function rewindTargetOfOutcome(text) {
2275
- if (typeof text !== "string") return void 0;
2276
- const seqMatch = /seq\s*(\d+)/i.exec(text);
2277
- if (seqMatch !== null) return parseSeqDigits(seqMatch[1]);
2278
- const hashMatch = /#(\d+)/.exec(text);
2279
- if (hashMatch !== null) return parseSeqDigits(hashMatch[1]);
2280
- const broadMatch = /(?:rewound|withdrawn|已撤回).*?(?:target\s+)?(\d+)/i.exec(text);
2281
- if (broadMatch !== null) return parseSeqDigits(broadMatch[1]);
2282
- }
2283
- /**
2284
- * Resolve the target of a rewind command: structured fields first
2285
- * (outcome.targetSeq / outcome.target / args.targetSeq / args.target /
2286
- * args.seq / args.raw / args[0]), then the outcome text — so
2287
- * locale-independent producers keep parsing without text heuristics (the
2288
- * args.seq guard skips the command's own seq).
2289
- * @param command - the command payload.
2290
- */
2291
- function rewindTargetOfCommand(command) {
2292
- if (command === null || typeof command !== "object") return void 0;
2293
- const rec = command;
2294
- const outcome = rec.outcome;
2295
- if (outcome !== null && typeof outcome === "object") {
2296
- const o = outcome;
2297
- if (typeof o.targetSeq === "number" && Number.isFinite(o.targetSeq)) return o.targetSeq;
2298
- if (typeof o.target === "number" && Number.isFinite(o.target)) return o.target;
2299
- }
2300
- const args = rec.args;
2301
- if (args !== null && typeof args === "object") {
2302
- const record = args;
2303
- if (typeof record.targetSeq === "number" && Number.isFinite(record.targetSeq)) return record.targetSeq;
2304
- if (typeof record.target === "number" && Number.isFinite(record.target)) return record.target;
2305
- if (typeof record.seq === "number" && Number.isFinite(record.seq) && record.seq !== rec.seq) return record.seq;
2306
- const rawish = record.raw ?? record["0"] ?? (Array.isArray(args) ? args[0] : void 0);
2307
- if (rawish !== void 0) {
2308
- const n = seqFromStringish(rawish);
2309
- if (n !== void 0) return n;
2310
- }
2311
- if (typeof record.target === "string") {
2312
- const n = seqFromStringish(record.target);
2313
- if (n !== void 0) return n;
2314
- }
2315
- }
2316
- if (outcome !== null && typeof outcome === "object") {
2317
- const text = outcome.text;
2318
- if (typeof text === "string") return rewindTargetOfOutcome(text);
2319
- }
2320
- }
2321
- /**
2322
- * Is this rewind command a preview/dry-run (marks the target hidden without
2323
- * an outcome)?
2324
- * @param command - the command payload.
2325
- */
2326
- function isRewindPreviewCommand(command) {
2327
- if (command.args === null || typeof command.args !== "object") return false;
2328
- const args = command.args;
2329
- return args.preview === true || args.dryRun === true;
2330
- }
2331
- /**
2332
- * Seq numbers of nodes carrying a rewind-hidden marker: host/plugin producers
2333
- * can tag the node record itself (rewindHidden), its data payload, or the
2334
- * payload's attributes map.
2335
- * @param chat - a chat snapshot (loosely typed on purpose).
2336
- */
2337
- function rewindMarkedSeqsOfChat(chat) {
2338
- const marked = /* @__PURE__ */ new Set();
2339
- for (const node of chatNodesOf(chat)) {
2340
- const data = node.data;
2341
- if (data === null || typeof data !== "object") continue;
2342
- const record = data;
2343
- const attrs = record.attributes;
2344
- if (attrs !== null && typeof attrs === "object" && attrs["data-dsh-rewind-hidden"] === true) {
2345
- if (typeof node.anchorSeq === "number") marked.add(node.anchorSeq);
2346
- continue;
2347
- }
2348
- if (record["data-dsh-rewind-hidden"] === true || node.rewindHidden === true) {
2349
- if (typeof node.anchorSeq === "number") marked.add(node.anchorSeq);
2350
- }
2351
- }
2352
- return marked;
2353
- }
2354
- /**
2355
- * Seq numbers a rewind hid directly: successful commands hide their own row;
2356
- * preview commands hide their target.
2357
- * @param chat - a chat snapshot (loosely typed on purpose).
2358
- */
2359
- function rewindHiddenSeqsOfChat(chat) {
2360
- const hidden = /* @__PURE__ */ new Set();
2361
- if (chat === null || typeof chat !== "object") return hidden;
2362
- for (const node of chatNodesOf(chat)) {
2363
- if (node.kind !== "command") continue;
2364
- const command = node.data;
2365
- if (command === null || typeof command !== "object") continue;
2366
- if (command.name !== "rewind") continue;
2367
- if (isRewindPreviewCommand(command)) {
2368
- if (typeof command.seq === "number") hidden.add(command.seq);
2369
- continue;
2370
- }
2371
- const outcome = command.outcome;
2372
- if (outcome === null || typeof outcome !== "object" || outcome.kind !== "success") continue;
2373
- const own = command.seq;
2374
- if (typeof own === "number") hidden.add(own);
2375
- }
2376
- return hidden;
2377
- }
2378
- /**
2379
- * The [target, sourceEventSeq] spans successful rewind commands cut — the
2380
- * range form of hiding, needed to filter sources that are NOT chat nodes
2381
- * (the host projection) by plain seq membership.
2382
- * @param chat - a chat snapshot (loosely typed on purpose).
2383
- */
2384
- function rewindSpansOfChat(chat) {
2385
- const spans = [];
2386
- if (chat === null || typeof chat !== "object") return spans;
2387
- for (const node of chatNodesOf(chat)) {
2388
- if (node.kind !== "command") continue;
2389
- const command = node.data;
2390
- if (command === null || typeof command !== "object") continue;
2391
- if (command.name !== "rewind") continue;
2392
- if (isRewindPreviewCommand(command)) continue;
2393
- const outcome = command.outcome;
2394
- if (outcome === null || typeof outcome !== "object") continue;
2395
- const record = outcome;
2396
- if (record.kind !== "success") continue;
2397
- const marker = record.sourceEventSeq;
2398
- if (typeof marker !== "number") continue;
2399
- const target = rewindTargetOfCommand(command);
2400
- if (target !== void 0) spans.push({
2401
- start: target,
2402
- end: marker
2403
- });
2404
- }
2405
- return spans;
2406
- }
2407
- /**
2408
- * Collect the seq numbers a rewind hid: preview commands hide their target
2409
- * directly; successful commands hide their own row plus every node anchored
2410
- * inside the rewound span [target, sourceEventSeq].
2411
- * @param chat - a chat snapshot (loosely typed on purpose).
2412
- */
2413
- function hiddenSeqsOfChat(chat) {
2414
- const hidden = rewindHiddenSeqsOfChat(chat);
2415
- for (const seq of rewindMarkedSeqsOfChat(chat)) hidden.add(seq);
2416
- const spans = rewindSpansOfChat(chat);
2417
- if (spans.length === 0) return hidden;
2418
- for (const node of chatNodesOf(chat)) {
2419
- const anchor = typeof node.anchorSeq === "number" ? node.anchorSeq : void 0;
2420
- if (anchor === void 0) continue;
2421
- if (spans.some((span) => anchor >= span.start && anchor <= span.end)) hidden.add(anchor);
2422
- }
2423
- return hidden;
2424
- }
2425
- /** The user-message node definition kind in ui-conversation. */
2426
- const INPUT_MESSAGE_KIND = "input-message";
2427
- /**
2428
- * Rebuild a chat row's DOM anchor key from the durable message id — the same
2429
- * engine formula as conversationContextKey(kind, id) for the input-message
2430
- * definition (`13:input-message<id>`), so projected entries can jump even
2431
- * when their chat node is not loaded yet.
2432
- * @param id - the durable message id carried by the projection entry.
2433
- */
2434
- function inputAnchorKeyOf(id) {
2435
- return `13:${INPUT_MESSAGE_KIND}${id}`;
2436
- }
2437
2059
  /**
2438
- * Validate one projected wire value into rail entries (defensive: the value
2439
- * crosses the wire and may be absent, partial, or stale).
2440
- * @param value - the raw useProjection(TIMELINE_PROJECTION_KEY) snapshot.
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.
2441
2068
  */
2442
- function normalizeProjectedTimeline(value) {
2443
- if (value === null || typeof value !== "object") return [];
2444
- const raw = value.messages;
2445
- if (!Array.isArray(raw)) return [];
2446
- const seen = /* @__PURE__ */ new Set();
2069
+ function normalizeOutlineItems(value) {
2070
+ if (!Array.isArray(value)) return [];
2447
2071
  const out = [];
2448
- for (const item of raw) {
2072
+ for (const item of value) {
2449
2073
  if (item === null || typeof item !== "object") continue;
2450
2074
  const rec = item;
2451
- if (typeof rec.seq !== "number" || !Number.isFinite(rec.seq)) continue;
2452
- if (seen.has(rec.seq)) continue;
2453
- seen.add(rec.seq);
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;
2454
2077
  out.push({
2078
+ turn: rec.turn,
2455
2079
  seq: rec.seq,
2456
- time: typeof rec.time === "number" ? rec.time : 0,
2457
- text: typeof rec.text === "string" ? rec.text.slice(0, MAX_TEXT_CHARS) : "",
2458
- ...typeof rec.id === "string" && rec.id !== "" ? { anchorKey: inputAnchorKeyOf(rec.id) } : {}
2459
- });
2460
- }
2461
- out.sort((a, b) => a.seq - b.seq);
2462
- return out;
2463
- }
2464
- /**
2465
- * Enumerate user messages from the loaded chat-node snapshot (the client-side
2466
- * collector; sorted by anchor sequence, rewind-hidden entries dropped).
2467
- * @param snapshot - a session snapshot or Chat snapshot (loosely typed).
2468
- */
2469
- function collectMessages(snapshot) {
2470
- const hidden = hiddenSeqsOfChat(snapshot);
2471
- const out = [];
2472
- for (const node of chatNodesOf(snapshot)) {
2473
- if (node.kind !== "user") continue;
2474
- if (!isVisible(node)) continue;
2475
- const data = node.data;
2476
- if (data === null || typeof data !== "object" || typeof data.time !== "number" || !Array.isArray(data.content)) continue;
2477
- const anchorKey = typeof node.key === "string" ? node.key : void 0;
2478
- if (anchorKey === void 0) continue;
2479
- const seq = typeof node.anchorSeq === "number" ? node.anchorSeq : 0;
2480
- if (hidden.has(seq)) continue;
2481
- out.push({
2482
- seq,
2483
- time: data.time,
2484
- text: userTextOf(data.content),
2485
- anchorKey
2080
+ prompt: typeof rec.prompt === "string" ? rec.prompt : "",
2081
+ response: typeof rec.response === "string" ? rec.response : ""
2486
2082
  });
2487
2083
  }
2488
- out.sort((a, b) => a.seq - b.seq);
2489
2084
  return out;
2490
2085
  }
2491
2086
  /**
2492
- * Count the user messages the loaded window holds WITHOUT collecting or
2493
- * sorting them — the fast gate railMessages uses to skip the merge path on
2494
- * the streaming hot path (see there).
2495
- * @param snapshot - the loaded chat snapshot.
2496
- */
2497
- function countWindowUserMessages(snapshot) {
2498
- let count = 0;
2499
- for (const node of chatNodesOf(snapshot)) if (node.kind === "user" && isVisible(node)) count += 1;
2500
- return count;
2501
- }
2502
- /**
2503
- * Rail entries: the host projection (whole-session index) MERGED with the
2504
- * loaded chat-node window. The projection normally wins outright, but a
2505
- * projection that lost its baseline (e.g. the plugin was hot-reloaded after
2506
- * the session's tail page seeded the client store) can miss early entries —
2507
- * the window fills those gaps whenever it holds them, and window entries
2508
- * lend their real anchor key to projected entries whose durable id was
2509
- * absent (early events without one). Locally-known rewind hiding applies to
2510
- * the merged list so a cut lands instantly, before the next projection push.
2511
- * @param snapshot - the session conversation snapshot (node window).
2512
- * @param projected - the raw bgTimeline projection value.
2513
- */
2514
- function railMessages(snapshot, projected) {
2515
- const projectedMessages = normalizeProjectedTimeline(projected);
2516
- const hidden = rewindHiddenSeqsOfChat(snapshot);
2517
- const marked = rewindMarkedSeqsOfChat(snapshot);
2518
- const spans = rewindSpansOfChat(snapshot);
2519
- const isHidden = (seq) => hidden.has(seq) || marked.has(seq) || spans.some((span) => seq >= span.start && seq <= span.end);
2520
- if (projectedMessages.length === 0) return collectMessages(snapshot);
2521
- const windowUserCount = countWindowUserMessages(snapshot);
2522
- const projectionLacksKeys = projectedMessages.some((m) => m.anchorKey === void 0);
2523
- if (windowUserCount <= projectedMessages.length && !projectionLacksKeys) return marked.size === 0 && hidden.size === 0 && spans.length === 0 ? projectedMessages : projectedMessages.filter((m) => !isHidden(m.seq));
2524
- const windowMessages = collectMessages(snapshot);
2525
- const bySeq = /* @__PURE__ */ new Map();
2526
- for (const m of projectedMessages) if (!isHidden(m.seq)) bySeq.set(m.seq, m);
2527
- for (const m of windowMessages) {
2528
- if (isHidden(m.seq)) continue;
2529
- const existing = bySeq.get(m.seq);
2530
- if (existing === void 0) {
2531
- bySeq.set(m.seq, m);
2532
- continue;
2533
- }
2534
- if (existing.anchorKey === void 0) bySeq.set(m.seq, {
2535
- ...existing,
2536
- anchorKey: m.anchorKey,
2537
- ...existing.text === "" && m.text !== "" ? { text: m.text } : {}
2087
+ * Rebuild the rail's full mark ladder exactly as the official ChatView does
2088
+ * (a mirror of `mergeTurnRailItems` in dsh-client-ui-chat): outline entries
2089
+ * seed every known turn as unloaded, loaded items then override with their
2090
+ * anchor key and richer previews, ascending by turn. The enhancer maps a
2091
+ * click to a mark by INDEX, so its ladder must match the RENDERED marks —
2092
+ * which are exactly this merge on an alpha.3 kernel; without the outline
2093
+ * value the merge degrades to the loaded items (the alpha.1/2 ladder).
2094
+ * @param loaded - the validated loaded-window items.
2095
+ * @param outline - the raw turnOutline wire value.
2096
+ */
2097
+ function mergeRailItems(loaded, outline) {
2098
+ const byTurn = /* @__PURE__ */ new Map();
2099
+ for (const entry of normalizeOutlineItems(outline)) byTurn.set(entry.turn, {
2100
+ turn: entry.turn,
2101
+ prompt: entry.prompt,
2102
+ response: entry.response
2103
+ });
2104
+ for (const item of loaded) {
2105
+ const preview = byTurn.get(item.turn);
2106
+ byTurn.set(item.turn, {
2107
+ turn: item.turn,
2108
+ prompt: item.prompt !== "" ? item.prompt : preview?.prompt ?? "",
2109
+ response: item.response !== "" ? item.response : preview?.response ?? "",
2110
+ anchorKey: item.anchorKey
2538
2111
  });
2539
2112
  }
2540
- return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
2541
- }
2542
- //#endregion
2543
- //#region src/client/timeline/legacy-rail-css.ts
2544
- /**
2545
- * Styles for the legacy port of the official turn rail (legacy-rail.tsx).
2546
- *
2547
- * A faithful port of `TurnNavigator.module.css` from dsh-client-ui-chat, kept
2548
- * on the plugin's own `dsbt-` prefix because this rail isportaled to body
2549
- * instead of living inside the chat scrollport. Metrics are the official ones
2550
- * verbatim (28px column, 10px tick spacing, 6px end inset, 12/18/20px tick
2551
- * widths, 420px height ceiling, 100px preview, 300px preview width).
2552
- *
2553
- * Glass + edge fade (unlike stock chrome): the rail is background plugin
2554
- * chrome over the user's art, so the hover preview joins the shared glass
2555
- * recipe under body[data-dsh-bg-glass] (fill = the painter's composer token
2556
- * + the shared blur/sheen — the same strength as the composer card), and
2557
- * the marks column gets the DeepSeek-web edge dissolve (top/bottom ticks
2558
- * fade instead of hard-clipping). The fade is paint-only, always on; the
2559
- * glass follows the wallpaper/panel-opacity sliders exactly like every
2560
- * other glassed surface. Both kernel generations render identically.
2561
- */
2562
- /** Unique id stamped on the injected style element (dedup key). */
2563
- const TIMELINE_CSS_TAG = "deepseek-harness-background/timeline";
2564
- /** Inject the rail stylesheet once. Idempotent (dedup key on the tag). */
2565
- function injectTimelineCss() {
2566
- if (typeof document === "undefined") return;
2567
- if (document.querySelector(`style[data-plugin-css="deepseek-harness-background/timeline"]`)) return;
2568
- const tag = document.createElement("style");
2569
- tag.dataset.plugin = "deepseek-harness-background";
2570
- tag.dataset.pluginCss = TIMELINE_CSS_TAG;
2571
- tag.textContent = TIMELINE_CSS;
2572
- document.head.appendChild(tag);
2573
- }
2574
- const TIMELINE_CSS = `
2575
- /* Portaled host: the official slot is a zero-height sticky box inside the
2576
- chat scrollport; a fixed box centred on the transcript band is the
2577
- equivalent outside it. */
2578
- .dsbt-slot {
2579
- position: fixed; top: 50%; right: 16px; transform: translateY(-50%);
2580
- z-index: 6; display: flex; align-items: center; pointer-events: none;
2581
- }
2582
- @media (max-width: 767px) { .dsbt-slot { display: none; } }
2583
-
2584
- .dsbt-rail {
2585
- position: relative; width: 28px; cursor: pointer; pointer-events: auto;
2586
- transition: height 220ms cubic-bezier(.2, .8, .2, 1);
2587
- height: min(var(--dsbt-natural-h, 12px), var(--dsbt-band, 420px), 420px);
2588
- }
2589
-
2590
- .dsbt-marks {
2591
- position: absolute; inset: var(--dsbt-inset, 6px) 0;
2592
- /* No edge dissolve here: the first and last tick dashes straddle the
2593
- marks box edges by 1px, so any fade zone sized off the box height
2594
- swallows them (at a 10-turn rail the 8% recipe rendered the ends at
2595
- ~14% opacity). The end ticks must stay fully painted. */
2596
- }
2597
-
2598
- .dsbt-markPosition {
2599
- position: absolute; top: min(var(--dsbt-natural, 0px), var(--dsbt-ratio, 0%));
2600
- right: 0; left: 0; height: 10px; transform: translateY(-50%);
2601
- transition: top 220ms cubic-bezier(.2, .8, .2, 1);
2602
- animation: dsbt-mark-enter 150ms ease-out;
2603
- }
2604
-
2605
- /* The rail owns pointer input for the whole column, so a mark is a keyboard
2606
- destination that paints one tick — never a mouse target of its own. */
2607
- .dsbt-mark {
2608
- position: absolute; inset: 0 0 0 auto; width: 20px; padding: 0; border: 0;
2609
- border-radius: 8px; background: transparent; cursor: pointer;
2610
- pointer-events: none;
2611
- }
2612
- .dsbt-mark::before {
2613
- position: absolute; top: 50%; right: 0; width: 12px; height: 2px;
2614
- border-radius: 2px; background: var(--dsw-alias-border-l4);
2615
- content: ''; transform: translateY(-50%);
2616
- transition: width 140ms ease, background-color 140ms ease;
2617
- }
2618
- .dsbt-markPreview::before { width: 18px; background: var(--dsw-alias-label-tertiary); }
2619
- .dsbt-markActive::before { width: 20px; background: var(--dsw-alias-label-primary); }
2620
- .dsbt-mark:focus-visible::before {
2621
- width: 20px; background: var(--dsw-alias-state-business-primary);
2622
- }
2623
- .dsbt-mark:focus-visible {
2624
- outline: 1px solid var(--dsw-alias-state-business-primary);
2625
- outline-offset: 2px;
2626
- }
2627
- /* Entries without an anchor key (early events lacking a durable id) cannot
2628
- jump; dim them instead of silently eating the click. */
2629
- .dsbt-mark:disabled::before { width: 8px; opacity: .5; }
2630
-
2631
- .dsbt-preview {
2632
- --dsbt-preview-h: 100px;
2633
- position: absolute;
2634
- top: clamp(
2635
- 0px,
2636
- calc(min(var(--dsbt-natural, 0px), var(--dsbt-ratio, 0%))
2637
- + var(--dsbt-inset, 6px) - var(--dsbt-preview-h) / 2),
2638
- calc(100% - var(--dsbt-preview-h))
2639
- );
2640
- right: calc(100% + 10px); box-sizing: border-box;
2641
- width: min(300px, calc(100vw - 120px)); max-height: var(--dsbt-preview-h);
2642
- overflow: hidden; padding: 10px 12px;
2643
- border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px;
2644
- color: var(--dsw-alias-label-primary);
2645
- background: var(--dsw-alias-bg-layer-1); box-shadow: var(--dsw-shadow-lv2);
2646
- font: var(--dsw-font-xs-strong-13); pointer-events: none;
2647
- display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4;
2648
- animation: dsbt-preview-enter 120ms ease-out;
2649
- transition: top 140ms cubic-bezier(.2, .8, .2, 1);
2650
- /* Content dissolve: a clamped preview's last lines soften out instead of
2651
- hard-cutting. No effect on short copy (the mask's fade zone rides the
2652
- empty tail) and no effect on ticks — it lives on the preview only. */
2653
- mask-image: linear-gradient(180deg, #000 86%, transparent 100%);
2654
- -webkit-mask-image: linear-gradient(180deg, #000 86%, transparent 100%);
2655
- }
2656
-
2657
- /* Hover preview is glass chrome just like the composer card: its official
2658
- fill is the opaque bg-layer-1 token, so it needs the explicit-fill
2659
- recipe (painter's composer token + shared blur/sheen chain). Gated on
2660
- data-dsh-bg-glass like every other glassed surface. */
2661
- body[data-dsh-bg-glass] .dsbt-preview {
2662
- background-color: var(--dsw-specific-input-major);
2663
- background-image: linear-gradient(180deg, rgba(255, 255, 255, var(--bg-glass-sheen, 0.07)), rgba(255, 255, 255, var(--bg-glass-sheen-mid, 0.02)) 38%, rgba(255, 255, 255, 0.01));
2664
- -webkit-backdrop-filter: blur(var(--bg-glass-blur, 16px)) saturate(var(--bg-glass-saturate, 1.42)) brightness(var(--bg-glass-brightness, 1)) contrast(1.01);
2665
- backdrop-filter: blur(var(--bg-glass-blur, 16px)) saturate(var(--bg-glass-saturate, 1.42)) brightness(var(--bg-glass-brightness, 1)) contrast(1.01);
2666
- }
2667
-
2668
- @keyframes dsbt-mark-enter { from { opacity: 0; } to { opacity: 1; } }
2669
- @keyframes dsbt-preview-enter {
2670
- from { opacity: 0; transform: translateX(4px); }
2671
- to { opacity: 1; transform: translateX(0); }
2672
- }
2673
-
2674
- @media (prefers-reduced-motion: reduce) {
2675
- .dsbt-rail, .dsbt-markPosition, .dsbt-mark::before, .dsbt-preview {
2676
- transition: none; animation: none;
2677
- }
2678
- }
2679
- `;
2680
- //#endregion
2681
- //#region src/client/timeline/legacy-rail.tsx
2682
- /**
2683
- * Legacy turn rail — the official `TurnNavigator` UI ported into this plugin
2684
- * for kernels that do not publish an official turn-navigation index
2685
- * (dsh <= 0.1.1).
2686
- *
2687
- * Structure, metrics and paints mirror `TurnNavigator.tsx` /
2688
- * `TurnNavigator.module.css` from dsh-client-ui-chat; styles live in
2689
- * ./legacy-rail-css.ts. The only differences are the ones a portaled box
2690
- * forces:
2691
- * - The official slot is a zero-height sticky box inside the chat scrollport;
2692
- * this one is a fixed box centred on the same band (the transcript minus
2693
- * the composer), positioned from the scrollport's live rect.
2694
- * - The official rail reads the turn index the kernel assembles; this one
2695
- * reads the shared backend (./source.ts), i.e. the host projection when it
2696
- * exists and the loaded node window otherwise. That is why it can offer
2697
- * turns the chat view has not loaded yet.
2698
- *
2699
- * Behaviour is identical to the enhanced official rail because both run the
2700
- * same jump engine (./jump.ts): a click glides instead of teleporting, and
2701
- * reaching for the rail pages older history in up to its uncompressed
2702
- * capacity with the reader's view held still.
2703
- */
2704
- if (typeof document !== "undefined") injectTimelineCss();
2705
- /** Composer height assumed when neither the seat nor the kernel's own var is available. */
2706
- const FALLBACK_COMPOSER_PX = 152;
2707
- /** The official stylesheet's own ceiling and band deduction. */
2708
- const RAIL_MAX_HEIGHT_PX = 420;
2709
- const RAIL_BAND_DEDUCTION_PX = 64;
2710
- /** How often the rail's host band and offset are re-measured. */
2711
- const MEASURE_INTERVAL_MS = 400;
2712
- /** Read one px-valued custom property off an element (undefined when absent). */
2713
- function cssPx(el, name) {
2714
- const raw = el.style.getPropertyValue(name);
2715
- if (raw === "") return void 0;
2716
- const px = Number.parseFloat(raw);
2717
- return Number.isFinite(px) ? px : void 0;
2718
- }
2719
- /**
2720
- * The band the rail may occupy: the transcript the reader actually sees, i.e.
2721
- * the scrollport minus the sticky composer stack — the official measurement,
2722
- * taken from the kernel's own vars when it publishes them (0.1.2+) and from
2723
- * the composer seat otherwise.
2724
- * @param sp - the conversation scrollport.
2725
- */
2726
- function bandOf(sp) {
2727
- const viewport = cssPx(sp, "--dsh-conversation-viewport-height") ?? sp.clientHeight;
2728
- const seat = sp.querySelector("[data-composer-seat]");
2729
- const composer = cssPx(sp, "--dsh-composer-height") ?? seat?.offsetHeight ?? FALLBACK_COMPOSER_PX;
2730
- return Math.max(0, Math.min(RAIL_MAX_HEIGHT_PX, viewport - composer - RAIL_BAND_DEDUCTION_PX));
2731
- }
2732
- /** Tick metrics for one count, mirroring the official `railSize`. */
2733
- function railSize(count) {
2734
- return {
2735
- ["--dsbt-natural-h"]: `${(count - 1) * 10 + 12}px`,
2736
- ["--dsbt-inset"]: `6px`
2737
- };
2738
- }
2739
- /** Position of one tick, mirroring the official `itemPosition`. */
2740
- function tickPosition(index, count) {
2741
- const ratio = count <= 1 ? 0 : index / (count - 1);
2742
- return {
2743
- ["--dsbt-natural"]: `${index * 10}px`,
2744
- ["--dsbt-ratio"]: `${ratio * 100}%`
2745
- };
2746
- }
2747
- /**
2748
- * The legacy turn rail.
2749
- * @param props - dock slot props plus the enabled gate.
2750
- * @returns the portaled rail, or null when it must not render.
2751
- */
2752
- function LegacyTimelineRail(props) {
2753
- const { sessionId, sessionsService, useProjection, enabled, t } = props;
2754
- const session = sessionId !== void 0 && sessionsService !== void 0 ? sessionsService.binding(sessionId)?.session : void 0;
2755
- const subscribeSession = react.default.useCallback((listener) => session === void 0 ? () => {} : session.subscribe(listener), [session]);
2756
- const snapshotSession = react.default.useCallback(() => session === void 0 ? void 0 : session.getSnapshot(), [session]);
2757
- const nodeSnapshot = react.default.useSyncExternalStore(subscribeSession, snapshotSession);
2758
- const projected = useProjection === void 0 ? void 0 : useProjection(TIMELINE_PROJECTION_KEY);
2759
- const entries = react.default.useMemo(() => railMessages(nodeSnapshot, projected), [nodeSnapshot, projected]);
2760
- const [activeIndex, setActiveIndex] = react.default.useState(-1);
2761
- const [previewIndex, setPreviewIndex] = react.default.useState(-1);
2762
- const [rightOffset, setRightOffset] = react.default.useState(16);
2763
- const [band, setBand] = react.default.useState(RAIL_MAX_HEIGHT_PX);
2764
- const aliveRef = react.default.useRef(true);
2765
- const warmingRef = react.default.useRef(false);
2766
- const railRef = react.default.useRef(null);
2767
- react.default.useEffect(() => {
2768
- aliveRef.current = true;
2769
- return () => {
2770
- aliveRef.current = false;
2771
- };
2772
- }, []);
2773
- const entriesRef = react.default.useRef(entries);
2774
- entriesRef.current = entries;
2775
- react.default.useEffect(() => {
2776
- if (entries.length === 0) return;
2777
- const indexByKey = /* @__PURE__ */ new Map();
2778
- entries.forEach((entry, index) => {
2779
- if (entry.anchorKey !== void 0) indexByKey.set(entry.anchorKey, index);
2780
- });
2781
- const sp = document.querySelector("[data-conversation-scroll]");
2782
- if (sp === null) return;
2783
- const rows = sp.querySelectorAll("[data-chat-anchor-key]");
2784
- let frame = 0;
2785
- const measure = () => {
2786
- frame = 0;
2787
- const rect = sp.getBoundingClientRect();
2788
- if (rect.height === 0) return;
2789
- const line = rect.top + rect.height * .4;
2790
- let best = -1;
2791
- let bestDist = Infinity;
2792
- for (const row of rows) {
2793
- const key = row.getAttribute("data-chat-anchor-key");
2794
- if (key === null) continue;
2795
- const index = indexByKey.get(key);
2796
- if (index === void 0) continue;
2797
- const r = row.getBoundingClientRect();
2798
- const dist = Math.abs(r.top + r.height / 2 - line);
2799
- if (dist < bestDist) {
2800
- bestDist = dist;
2801
- best = index;
2802
- }
2803
- }
2804
- setActiveIndex(best);
2805
- };
2806
- const schedule = () => {
2807
- if (frame === 0) frame = window.requestAnimationFrame(measure);
2808
- };
2809
- sp.addEventListener("scroll", schedule, { passive: true });
2810
- const timer = window.setInterval(schedule, 2e3);
2811
- schedule();
2812
- return () => {
2813
- sp.removeEventListener("scroll", schedule);
2814
- window.clearInterval(timer);
2815
- if (frame !== 0) cancelAnimationFrame(frame);
2816
- };
2817
- }, [entries]);
2818
- react.default.useEffect(() => {
2819
- let raf = 0;
2820
- const measure = () => {
2821
- raf = 0;
2822
- const sp = document.querySelector("[data-conversation-scroll]");
2823
- if (sp === null) return;
2824
- const rect = sp.getBoundingClientRect();
2825
- if (rect.width === 0 && rect.height === 0) return;
2826
- setBand(bandOf(sp));
2827
- const next = Math.max(8, Math.round(window.innerWidth - rect.right + 12));
2828
- setRightOffset((prev) => Math.abs(prev - next) > .5 ? next : prev);
2829
- };
2830
- const schedule = () => {
2831
- if (raf === 0) raf = window.requestAnimationFrame(measure);
2832
- };
2833
- const sp = document.querySelector("[data-conversation-scroll]");
2834
- const ro = typeof ResizeObserver === "function" && sp !== null ? new ResizeObserver(schedule) : null;
2835
- if (ro !== null && sp !== null) ro.observe(sp);
2836
- window.addEventListener("resize", schedule);
2837
- const timer = window.setInterval(schedule, MEASURE_INTERVAL_MS);
2838
- measure();
2839
- return () => {
2840
- if (raf !== 0) cancelAnimationFrame(raf);
2841
- ro?.disconnect();
2842
- window.removeEventListener("resize", schedule);
2843
- window.clearInterval(timer);
2844
- };
2845
- }, []);
2846
- const navigate = react.default.useCallback((entry) => {
2847
- if (entry.anchorKey === void 0) return;
2848
- if (sessionsService === void 0 || sessionId === void 0) return;
2849
- jumpToMessage(sessionsService, sessionId, entry.anchorKey, { followZonePx: 25 }).catch(() => {});
2850
- }, [sessionsService, sessionId]);
2851
- const warm = react.default.useCallback(async () => {
2852
- if (session === void 0) return;
2853
- if (warmingRef.current) return;
2854
- const sp = document.querySelector("[data-conversation-scroll]");
2855
- if (sp === null) return;
2856
- warmingRef.current = true;
2857
- try {
2858
- await warmHistory(session, sp, {
2859
- followZonePx: 25,
2860
- alive: () => aliveRef.current,
2861
- countOf: () => entriesRef.current.length,
2862
- capacity: railCapacityOf(railRef.current)
2863
- });
2864
- } finally {
2865
- warmingRef.current = false;
2866
- }
2867
- }, [session]);
2868
- if (!enabled || sessionId === void 0 || sessionsService === void 0 || entries.length < 2) return null;
2869
- const count = entries.length;
2870
- const preview = previewIndex >= 0 && previewIndex < count ? entries[previewIndex] : void 0;
2871
- return react_dom.default.createPortal(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2872
- className: "dsbt-slot",
2873
- style: { right: rightOffset },
2874
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
2875
- className: "dsbt-rail",
2876
- ref: railRef,
2877
- style: {
2878
- ...railSize(count),
2879
- ["--dsbt-band"]: `${band}px`
2880
- },
2881
- "aria-label": t("timeline.railLabel"),
2882
- onPointerMove: (event) => {
2883
- setPreviewIndex(indexAtPointer(count, event.currentTarget, event.clientY));
2884
- },
2885
- onPointerLeave: () => {
2886
- setPreviewIndex(-1);
2887
- },
2888
- onPointerEnter: () => {
2889
- warm();
2890
- },
2891
- onFocus: () => {
2892
- warm();
2893
- },
2894
- onClick: (event) => {
2895
- const index = indexForEvent(event.currentTarget, event.nativeEvent);
2896
- const entry = entries[index];
2897
- if (entry !== void 0) navigate(entry);
2898
- },
2899
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2900
- className: "dsbt-marks",
2901
- children: entries.map((entry, index) => {
2902
- const active = index === activeIndex;
2903
- const cls = active ? "dsbt-mark dsbt-markActive" : index === previewIndex ? "dsbt-mark dsbt-markPreview" : "dsbt-mark";
2904
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2905
- className: "dsbt-markPosition",
2906
- style: tickPosition(index, count),
2907
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2908
- type: "button",
2909
- className: cls,
2910
- disabled: entry.anchorKey === void 0,
2911
- "aria-label": t("timeline.jump", { turn: index + 1 }),
2912
- "aria-current": active ? "true" : void 0,
2913
- onFocus: () => {
2914
- setPreviewIndex(index);
2915
- },
2916
- onBlur: () => {
2917
- setPreviewIndex(-1);
2918
- }
2919
- })
2920
- }, entry.seq);
2921
- })
2922
- }), preview !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2923
- role: "tooltip",
2924
- className: "dsbt-preview",
2925
- style: tickPosition(previewIndex, count),
2926
- children: preview.text === "" ? t("timeline.noText") : preview.text
2927
- })]
2928
- })
2929
- }), document.body);
2113
+ if (byTurn.size === 0) return [];
2114
+ return [...byTurn.values()].sort((left, right) => left.turn - right.turn);
2930
2115
  }
2931
2116
  //#endregion
2932
2117
  //#region src/client/timeline/official-enhance.tsx
2933
2118
  /**
2934
2119
  * Official-rail enhancer — behaviour-only improvements to the kernel's own
2935
- * turn navigator (dsh >= 0.1.2, `TurnNavigator` in dsh-client-ui-chat).
2120
+ * turn navigator (dsh >= 0.1.2-rc.1, `TurnNavigator` in dsh-client-ui-chat).
2936
2121
  *
2937
2122
  * This component renders NOTHING. It borrows the official rail exactly as the
2938
- * kernel painted it and fixes two behaviours:
2123
+ * kernel painted it and fixes its behaviour, scoped to what the frame-style
2124
+ * rail (dsh >= 0.1.2-alpha.3, unchanged on 0.1.2-rc.1) still lacks — told
2125
+ * apart by capability, never by version compare:
2939
2126
  *
2940
- * 1. Smooth jump. The official `navigateToTurn` is one assignment
2941
- * (`el.scrollTop += flowTop(row, el) - 24`); a click across a long
2942
- * transcript teleports. Clicks are intercepted in the CAPTURE phase and
2943
- * re-run through the shared glide instead.
2944
- * 2. Reachable history. The official rail only lists turns the client has
2945
- * already loaded, so anything behind the "load earlier" button has no mark
2946
- * at all. Hovering or focusing the rail pages older history in — only up
2947
- * to the rail's own uncompressed capacity, so the marks never degrade into
2948
- * a solid bar — with the scroll position compensated so the reader's view
2949
- * never moves.
2127
+ * Smooth jump. The official `navigateToTurn` is one assignment
2128
+ * (`el.scrollTop += flowTop(row, el) - 24`); a click across a long transcript
2129
+ * teleports. Clicks on LOADED marks are intercepted in the capture phase and
2130
+ * re-run through the shared glide instead. The frame-style rail also renders
2131
+ * marks for turns OUTSIDE the loaded window (fed by the whole-log
2132
+ * `turnOutline` projection); those jumps page history through the kernel's
2133
+ * own loadThrough machinery — marks without an anchor key are left to it, and
2134
+ * the click index is resolved against the same merged ladder the kernel
2135
+ * renders. While one of those kernel jumps is still paging (a mark pulses
2136
+ * `aria-busy`), the plugin stands down entirely: the kernel's loaded branch
2137
+ * cancels its own pending jump before landing, and an interception would
2138
+ * bypass that cancellation.
2950
2139
  *
2951
2140
  * State-ownership note (why intercepting the official click loses nothing):
2952
2141
  * the kernel's scrollport (`data-conversation-scroll`, ChatView's
@@ -2959,8 +2148,7 @@ window.__ModuleLoader__.load({
2959
2148
  * handler's synchronous `setActiveTurn(item.turn)`, which its own
2960
2149
  * scroll-driven active derivation re-settles by the time the glide lands.
2961
2150
  *
2962
- * Both go through the shared backend (jump.ts), which is why the legacy port
2963
- * behaves identically.
2151
+ * The jump goes through the shared backend (jump.ts).
2964
2152
  *
2965
2153
  * Interception notes: React 18 dispatches `onClick` from a listener on the
2966
2154
  * root container during the BUBBLE phase. A capture listener on the rail
@@ -2972,7 +2160,13 @@ window.__ModuleLoader__.load({
2972
2160
  * Nothing here touches the official DOM: no nodes are injected into a
2973
2161
  * React-owned subtree and no official class or paint is overridden.
2974
2162
  */
2975
- /** Structural anchor of the official rail (its inline style carries the rail's own metrics). */
2163
+ /**
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.
2169
+ */
2976
2170
  const OFFICIAL_RAIL_SELECTOR = "[data-conversation-scroll] nav[style*=\"--turn-natural-height\"]";
2977
2171
  /**
2978
2172
  * Rails claimed by an enhancer instance, keyed by the owning instance's
@@ -2988,32 +2182,22 @@ window.__ModuleLoader__.load({
2988
2182
  /** How often the official rail is looked for (it mounts with the chat view). */
2989
2183
  const RAIL_POLL_MS = 400;
2990
2184
  /**
2991
- * The enhancer: no visual output, only the two behaviour fixes.
2185
+ * The enhancer: no visual output, only the behaviour fixes the running rail
2186
+ * generation lacks.
2992
2187
  * @param props - dock slot props plus the enabled gate.
2993
2188
  * @returns null (always).
2994
2189
  */
2995
2190
  function OfficialTimelineEnhancer(props) {
2996
- const { sessionId, sessionsService, useChat, enabled } = props;
2997
- const items = normalizeNavigationItems(useChat === void 0 ? void 0 : useChat((snapshot) => {
2191
+ const { sessionId, sessionsService, useChat, useProjection, enabled } = props;
2192
+ const ladder = mergeRailItems(normalizeNavigationItems(useChat === void 0 ? void 0 : useChat((snapshot) => {
2998
2193
  const nav = snapshot?.navigation;
2999
2194
  return typeof nav?.items === "function" ? nav.items() : void 0;
3000
- }));
3001
- const session = sessionId !== void 0 && sessionsService !== void 0 ? sessionsService.binding(sessionId)?.session : void 0;
2195
+ })), useProjection === void 0 ? void 0 : useProjection("turnOutline"));
3002
2196
  const [rail, setRail] = react.default.useState(null);
3003
- const itemsRef = react.default.useRef(items);
3004
- itemsRef.current = items;
2197
+ const ladderRef = react.default.useRef(ladder);
2198
+ ladderRef.current = ladder;
3005
2199
  const railRef = react.default.useRef(rail);
3006
2200
  railRef.current = rail;
3007
- const aliveRef = react.default.useRef(true);
3008
- const enabledRef = react.default.useRef(enabled);
3009
- enabledRef.current = enabled;
3010
- const warmingRef = react.default.useRef(false);
3011
- react.default.useEffect(() => {
3012
- aliveRef.current = true;
3013
- return () => {
3014
- aliveRef.current = false;
3015
- };
3016
- }, []);
3017
2201
  const claimToken = react.default.useMemo(() => Symbol("dsh-bg-timeline-claim"), []);
3018
2202
  react.default.useEffect(() => {
3019
2203
  const check = () => {
@@ -3038,6 +2222,7 @@ window.__ModuleLoader__.load({
3038
2222
  };
3039
2223
  }, [claimToken]);
3040
2224
  const navigate = react.default.useCallback((item) => {
2225
+ if (item.anchorKey === void 0) return;
3041
2226
  if (sessionsService === void 0 || sessionId === void 0) return;
3042
2227
  const scrollport = rail === null ? null : rail.closest("[data-conversation-scroll]");
3043
2228
  jumpToMessage(sessionsService, sessionId, item.anchorKey, {
@@ -3050,34 +2235,13 @@ window.__ModuleLoader__.load({
3050
2235
  sessionId,
3051
2236
  rail
3052
2237
  ]);
3053
- /**
3054
- * Page older history in until the rail is full or the log is exhausted.
3055
- * Each page is compensated so the reader's view stays put (see
3056
- * compensatedLoadOlder for why the kernel cannot do this for us).
3057
- */
3058
- const warm = react.default.useCallback(async () => {
3059
- if (session === void 0 || rail === null) return;
3060
- if (warmingRef.current) return;
3061
- const scrollport = rail.closest("[data-conversation-scroll]");
3062
- if (scrollport === null) return;
3063
- warmingRef.current = true;
3064
- try {
3065
- await warmHistory(session, scrollport, {
3066
- followZonePx: 24,
3067
- alive: () => aliveRef.current && enabledRef.current,
3068
- countOf: () => itemsRef.current.length,
3069
- capacity: railCapacityOf(rail)
3070
- });
3071
- } finally {
3072
- warmingRef.current = false;
3073
- }
3074
- }, [session, rail]);
3075
2238
  react.default.useEffect(() => {
3076
2239
  if (!enabled || rail === null) return;
3077
2240
  const onClick = (event) => {
2241
+ if (rail.querySelector("[aria-busy=\"true\"]") !== null) return;
3078
2242
  const index = indexForEvent(rail, event);
3079
- const item = itemsRef.current[index];
3080
- if (item === void 0) return;
2243
+ const item = ladderRef.current[index];
2244
+ if (item === void 0 || item.anchorKey === void 0) return;
3081
2245
  event.preventDefault();
3082
2246
  event.stopImmediatePropagation();
3083
2247
  navigate(item);
@@ -3091,70 +2255,43 @@ window.__ModuleLoader__.load({
3091
2255
  rail,
3092
2256
  navigate
3093
2257
  ]);
3094
- react.default.useEffect(() => {
3095
- if (!enabled || rail === null) return;
3096
- const start = () => {
3097
- warm();
3098
- };
3099
- rail.addEventListener("pointerenter", start);
3100
- rail.addEventListener("focusin", start);
3101
- return () => {
3102
- rail.removeEventListener("pointerenter", start);
3103
- rail.removeEventListener("focusin", start);
3104
- };
3105
- }, [
3106
- enabled,
3107
- rail,
3108
- warm
3109
- ]);
3110
2258
  return null;
3111
2259
  }
3112
2260
  //#endregion
3113
2261
  //#region src/client/timeline/index.tsx
3114
2262
  /**
3115
- * Dock-slot entry for the conversation timeline: detects which frontend the
3116
- * running kernel supports and mounts exactly one of them.
2263
+ * Dock-slot entry for the conversation timeline: mounts the official-rail
2264
+ * enhancer on kernels that publish the turn-navigation index.
3117
2265
  *
3118
- * Detection is a capability check on the slot props, not a version compare:
3119
- * the official turn rail is rendered from the very index `ui-chat` publishes
3120
- * as a session hook (`useChat(s => s.navigation.items())`, dsh >= 0.1.2), so
3121
- * the presence of that hook IS the presence of the official rail. Comparing
3122
- * version strings would break on pre-releases, forks, and deployments that
3123
- * mount a different conversation target.
2266
+ * Supported dsh baseline: >= 0.1.2-rc.1. Every such kernel ships the official
2267
+ * turn rail (`TurnNavigator` in dsh-client-ui-chat), rendered from the very
2268
+ * index `ui-chat` publishes as a session hook (`useChat(s => s.navigation.items())`),
2269
+ * so the presence of that hook IS the presence of the official rail. This
2270
+ * plugin therefore never renders a rail of its own — it only improves the
2271
+ * official one's behaviour (smooth glide for loaded marks, standing down
2272
+ * while the kernel's own loadThrough jump is still paging).
3124
2273
  *
3125
- * - `useChat` present -> 'enhance': the official rail stays on screen and
3126
- * OfficialTimelineEnhancer improves only its behaviour.
3127
- * - `useChat` absent -> 'legacy': no official rail exists, so this plugin
3128
- * renders its own port of it from the shared backend.
3129
- *
3130
- * The detected mode is published to ./mode-store.ts because the settings row
3131
- * (root-scoped, no session) cannot probe the kernel itself but must switch its
3132
- * copy between "conversation timeline" and "conversation timeline enhancement".
2274
+ * The hook-missing branch is a defensive no-op, not a second frontend: on
2275
+ * kernels without the official rail (everything before 0.1.2) this plugin
2276
+ * renders nothing, exactly as the supported-version contract requires
2277
+ * (README directs those users to an older plugin release).
3133
2278
  */
3134
2279
  /**
3135
2280
  * Mount the timeline frontend the running kernel supports.
3136
2281
  * @param props - dock slot props.
3137
- * @returns the enhancer or the legacy rail.
2282
+ * @returns the enhancer, or nothing on kernels without the official rail.
3138
2283
  */
3139
2284
  function TimelineBridge(props) {
3140
2285
  const { useChat, t, ...rest } = props;
3141
2286
  const settings = react.default.useSyncExternalStore(settingsClient.subscribe, settingsClient.getSnapshot);
3142
2287
  const enabled = settings.value?.timeline !== false && settings.status !== "loading";
3143
- const mode = useChat === void 0 ? "legacy" : "enhance";
3144
- react.default.useEffect(() => {
3145
- reportTimelineMode(mode);
3146
- }, [mode]);
3147
- if (mode === "enhance") return react.default.createElement(OfficialTimelineEnhancer, {
2288
+ if (useChat === void 0) return null;
2289
+ return react.default.createElement(OfficialTimelineEnhancer, {
3148
2290
  ...rest,
3149
2291
  useChat,
3150
2292
  enabled,
3151
2293
  t
3152
2294
  });
3153
- return react.default.createElement(LegacyTimelineRail, {
3154
- ...rest,
3155
- enabled,
3156
- t
3157
- });
3158
2295
  }
3159
2296
  /** localStorage prefix the removed key-point bookmark feature used (< 0.5). */
3160
2297
  const MARKS_STORAGE_PREFIX = "dsbt_marks_";