deepseek-harness-background 0.5.2 → 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/README.md +172 -168
- package/README.zh.md +170 -165
- package/lib/client.js +550 -1538
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -100
- package/package.json +85 -85
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 (
|
|
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,14 +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.
|
|
972
|
-
"background.timelineEnhanceNarrowHint": "为官方会话时间线的已加载轮次补上平滑跳转",
|
|
973
|
-
"timeline.railLabel": "轮次导航",
|
|
974
|
-
"timeline.noText": "(无文本内容)",
|
|
975
|
-
"timeline.jump": "跳转到第 {turn} 轮"
|
|
964
|
+
"background.timelineEnhanceNarrowHint": "为官方会话时间线的已加载轮次补上平滑跳转"
|
|
976
965
|
};
|
|
977
966
|
/** English copy. */
|
|
978
967
|
const en = {
|
|
@@ -1000,14 +989,8 @@ window.__ModuleLoader__.load({
|
|
|
1000
989
|
"background.clear": "Clear background",
|
|
1001
990
|
"background.uploadFailed": "Upload failed, try again",
|
|
1002
991
|
"background.saveFailed": "Save failed, try again",
|
|
1003
|
-
"background.timeline": "Conversation timeline",
|
|
1004
|
-
"background.timelineHint": "Turn rail at the right edge; hover to preview a turn and reach earlier history",
|
|
1005
992
|
"background.timelineEnhance": "Conversation timeline enhancement",
|
|
1006
|
-
"background.
|
|
1007
|
-
"background.timelineEnhanceNarrowHint": "Adds smooth jumps for the rail's loaded turns",
|
|
1008
|
-
"timeline.railLabel": "Turn navigation",
|
|
1009
|
-
"timeline.noText": "(no text)",
|
|
1010
|
-
"timeline.jump": "Jump to turn {turn}"
|
|
993
|
+
"background.timelineEnhanceNarrowHint": "Adds smooth jumps for the rail's loaded turns"
|
|
1011
994
|
};
|
|
1012
995
|
//#endregion
|
|
1013
996
|
//#region node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
|
|
@@ -1131,31 +1114,6 @@ window.__ModuleLoader__.load({
|
|
|
1131
1114
|
/** Shared client for the painter and the settings row. */
|
|
1132
1115
|
const settingsClient = new SettingsClient();
|
|
1133
1116
|
//#endregion
|
|
1134
|
-
//#region src/client/timeline/mode-store.ts
|
|
1135
|
-
let mode = "legacy";
|
|
1136
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
1137
|
-
/** @returns the currently detected mode. */
|
|
1138
|
-
function timelineMode() {
|
|
1139
|
-
return mode;
|
|
1140
|
-
}
|
|
1141
|
-
/** Observe mode changes. @returns the disposer. */
|
|
1142
|
-
function subscribeTimelineMode(listener) {
|
|
1143
|
-
listeners.add(listener);
|
|
1144
|
-
return () => {
|
|
1145
|
-
listeners.delete(listener);
|
|
1146
|
-
};
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Publish the detected mode (called by the session-scoped dock entry).
|
|
1150
|
-
* No-ops when the mode is unchanged, so a re-render cannot notify.
|
|
1151
|
-
* @param next - the mode to publish.
|
|
1152
|
-
*/
|
|
1153
|
-
function reportTimelineMode(next) {
|
|
1154
|
-
if (mode === next) return;
|
|
1155
|
-
mode = next;
|
|
1156
|
-
for (const listener of listeners) listener();
|
|
1157
|
-
}
|
|
1158
|
-
//#endregion
|
|
1159
1117
|
//#region \0dsh-css:D:\Project\deepseek-harness-background\src\client\SettingsRow.module.css.mjs
|
|
1160
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}}";
|
|
1161
1119
|
const tagId = "deepseek-harness-background/SettingsRow.module.css";
|
|
@@ -1167,41 +1125,41 @@ window.__ModuleLoader__.load({
|
|
|
1167
1125
|
document.head.appendChild(tag);
|
|
1168
1126
|
}
|
|
1169
1127
|
var SettingsRow_module_css_default = {
|
|
1170
|
-
"
|
|
1171
|
-
"urlRow": "-ozqdq_urlRow",
|
|
1128
|
+
"enabledBadge": "-ozqdq_enabledBadge",
|
|
1172
1129
|
"previewImg": "-ozqdq_previewImg",
|
|
1173
|
-
"
|
|
1174
|
-
"switchKnob": "-ozqdq_switchKnob",
|
|
1130
|
+
"btnPrimary": "-ozqdq_btnPrimary",
|
|
1175
1131
|
"hint": "-ozqdq_hint",
|
|
1176
|
-
"slider": "-ozqdq_slider",
|
|
1177
|
-
"segLabel": "-ozqdq_segLabel",
|
|
1178
1132
|
"segActive": "-ozqdq_segActive",
|
|
1179
|
-
"
|
|
1180
|
-
"
|
|
1181
|
-
"
|
|
1182
|
-
"
|
|
1183
|
-
"segRow": "-ozqdq_segRow",
|
|
1184
|
-
"titleRow": "-ozqdq_titleRow",
|
|
1185
|
-
"error": "-ozqdq_error",
|
|
1133
|
+
"clearRow": "-ozqdq_clearRow",
|
|
1134
|
+
"segBtn": "-ozqdq_segBtn",
|
|
1135
|
+
"previewGlassText": "-ozqdq_previewGlassText",
|
|
1136
|
+
"btn": "-ozqdq_btn",
|
|
1186
1137
|
"preview": "-ozqdq_preview",
|
|
1138
|
+
"clearBtn": "-ozqdq_clearBtn",
|
|
1139
|
+
"urlInput": "-ozqdq_urlInput",
|
|
1187
1140
|
"btnDanger": "-ozqdq_btnDanger",
|
|
1188
|
-
"
|
|
1189
|
-
"
|
|
1141
|
+
"urlRow": "-ozqdq_urlRow",
|
|
1142
|
+
"switchKnob": "-ozqdq_switchKnob",
|
|
1143
|
+
"segRow": "-ozqdq_segRow",
|
|
1144
|
+
"segLabel": "-ozqdq_segLabel",
|
|
1190
1145
|
"previewGlass": "-ozqdq_previewGlass",
|
|
1146
|
+
"header": "-ozqdq_header",
|
|
1147
|
+
"sectionLabel": "-ozqdq_sectionLabel",
|
|
1148
|
+
"sliderLabel": "-ozqdq_sliderLabel",
|
|
1149
|
+
"desc": "-ozqdq_desc",
|
|
1191
1150
|
"sliderValue": "-ozqdq_sliderValue",
|
|
1192
|
-
"
|
|
1193
|
-
"
|
|
1151
|
+
"section": "-ozqdq_section",
|
|
1152
|
+
"titleRow": "-ozqdq_titleRow",
|
|
1153
|
+
"previewEmpty": "-ozqdq_previewEmpty",
|
|
1194
1154
|
"sliderRow": "-ozqdq_sliderRow",
|
|
1195
|
-
"
|
|
1196
|
-
"
|
|
1155
|
+
"previewScrim": "-ozqdq_previewScrim",
|
|
1156
|
+
"slider": "-ozqdq_slider",
|
|
1157
|
+
"segGroup": "-ozqdq_segGroup",
|
|
1158
|
+
"error": "-ozqdq_error",
|
|
1197
1159
|
"title": "-ozqdq_title",
|
|
1198
|
-
"
|
|
1199
|
-
"
|
|
1200
|
-
"
|
|
1201
|
-
"clearRow": "-ozqdq_clearRow",
|
|
1202
|
-
"urlInput": "-ozqdq_urlInput",
|
|
1203
|
-
"clearBtn": "-ozqdq_clearBtn",
|
|
1204
|
-
"previewEmpty": "-ozqdq_previewEmpty"
|
|
1160
|
+
"row": "-ozqdq_row",
|
|
1161
|
+
"sourceRow": "-ozqdq_sourceRow",
|
|
1162
|
+
"switch": "-ozqdq_switch"
|
|
1205
1163
|
};
|
|
1206
1164
|
//#endregion
|
|
1207
1165
|
//#region src/client/SettingsRow.tsx
|
|
@@ -1367,9 +1325,8 @@ window.__ModuleLoader__.load({
|
|
|
1367
1325
|
*/
|
|
1368
1326
|
function BackgroundSettingsRow({ t }) {
|
|
1369
1327
|
const snapshot = (0, react.useSyncExternalStore)(settingsClient.subscribe, readSnapshot);
|
|
1370
|
-
const
|
|
1371
|
-
const
|
|
1372
|
-
const timelineHintKey = navMode === "narrow" ? "background.timelineEnhanceNarrowHint" : navMode === "enhance" ? "background.timelineEnhanceHint" : "background.timelineHint";
|
|
1328
|
+
const timelineLabelKey = "background.timelineEnhance";
|
|
1329
|
+
const timelineHintKey = "background.timelineEnhanceNarrowHint";
|
|
1373
1330
|
const [draft, setDraft] = (0, react.useState)(DEFAULTS);
|
|
1374
1331
|
const [uploading, setUploading] = (0, react.useState)(false);
|
|
1375
1332
|
const [urlText, setUrlText] = (0, react.useState)("");
|
|
@@ -1738,1325 +1695,185 @@ window.__ModuleLoader__.load({
|
|
|
1738
1695
|
]
|
|
1739
1696
|
});
|
|
1740
1697
|
}
|
|
1741
|
-
//#endregion
|
|
1742
|
-
//#region src/client/timeline/jump.ts
|
|
1743
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1744
|
-
/** How long one jump may keep paging older history before giving up. */
|
|
1745
|
-
const JUMP_PAGE_DEADLINE_MS = 3e4;
|
|
1746
|
-
/** How long to wait for the chat view to commit the target row. */
|
|
1747
|
-
const JUMP_ROW_WAIT_MS = 3e3;
|
|
1748
|
-
/** Distance from the scrollport top the official ChatView lands a jumped row at. */
|
|
1749
|
-
const OFFICIAL_LANDING_INSET_PX = 24;
|
|
1750
|
-
/** Distances under this snap instead of animating. */
|
|
1751
|
-
const MIN_GLIDE_DISTANCE_PX = 8;
|
|
1752
|
-
/** Glide duration floor / cap — a far-boundary jump stays a tangible slide. */
|
|
1753
|
-
const MIN_GLIDE_MS = 280;
|
|
1754
|
-
const MAX_GLIDE_MS = 900;
|
|
1755
|
-
/** How many rounds one warm-up page waits for its prepend to commit. */
|
|
1756
|
-
const WARM_PAGE_SETTLE_ROUNDS = 8;
|
|
1757
|
-
/** Pause between those rounds. */
|
|
1758
|
-
const WARM_PAGE_SETTLE_MS = 25;
|
|
1759
|
-
/** Wall-clock budget for one warm-up run. */
|
|
1760
|
-
const WARM_DEADLINE_MS = 15e3;
|
|
1761
|
-
/** Page budget for one warm-up run. */
|
|
1762
|
-
const WARM_MAX_PAGES = 40;
|
|
1763
|
-
/** Every mounted conversation scrollport (multi-column layouts have several). */
|
|
1764
|
-
function conversationScrollports() {
|
|
1765
|
-
return Array.from(document.querySelectorAll("[data-conversation-scroll]"));
|
|
1766
|
-
}
|
|
1767
1698
|
/**
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
* node store, unreachable from `session.getSnapshot()` — its SessionSnapshot
|
|
1772
|
-
* faces no node table at all), but the rendered DOM is the ground truth both
|
|
1773
|
-
* share, so a jump probes it FIRST before deciding whether paging is needed.
|
|
1774
|
-
* @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).
|
|
1775
1702
|
*/
|
|
1776
|
-
function
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
return
|
|
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;
|
|
1780
1707
|
}
|
|
1781
1708
|
/**
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
* @param
|
|
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).
|
|
1789
1716
|
*/
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
const deadline = Date.now() + timeoutMs;
|
|
1793
|
-
for (;;) {
|
|
1794
|
-
for (const scrollport of conversationScrollports()) {
|
|
1795
|
-
const row = scrollport.querySelector(selector);
|
|
1796
|
-
if (row !== null) return row;
|
|
1797
|
-
}
|
|
1798
|
-
if (Date.now() >= deadline) return null;
|
|
1799
|
-
await delay(40);
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
/** Maximum scrollable offset of a conversation scrollport. */
|
|
1803
|
-
function scrollFloorOf(el) {
|
|
1804
|
-
return Math.max(0, el.scrollHeight - el.clientHeight);
|
|
1717
|
+
function isFrameRail(rail) {
|
|
1718
|
+
return rail !== null && rail.style.getPropertyValue("--turn-scroll-top") !== "";
|
|
1805
1719
|
}
|
|
1806
1720
|
/**
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
* the
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
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.
|
|
1812
1731
|
*/
|
|
1813
|
-
function
|
|
1814
|
-
const
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
|
|
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)));
|
|
1818
1739
|
}
|
|
1819
1740
|
/**
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
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.
|
|
1825
1757
|
*/
|
|
1826
|
-
function
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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;
|
|
1831
1770
|
}
|
|
1832
1771
|
/**
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
1836
|
-
* @param
|
|
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.
|
|
1837
1776
|
*/
|
|
1838
|
-
function
|
|
1839
|
-
if (!
|
|
1840
|
-
|
|
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;
|
|
1841
1796
|
}
|
|
1842
1797
|
/**
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
* @param
|
|
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.
|
|
1851
1806
|
*/
|
|
1852
|
-
function
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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;
|
|
1859
1823
|
}
|
|
1860
1824
|
/**
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
return new Promise((resolve) => {
|
|
1871
|
-
requestAnimationFrame(() => requestAnimationFrame(() => window.setTimeout(resolve, 0)));
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
/**
|
|
1875
|
-
* Animate el.scrollTop toward target with an ease-in-out cubic profile.
|
|
1876
|
-
* Wheel / touch / keyboard-scroll input cancels the glide so the reader
|
|
1877
|
-
* takes over immediately. Resolves true when the glide completed, false
|
|
1878
|
-
* when the reader cancelled it.
|
|
1879
|
-
* @param el - the conversation scrollport.
|
|
1880
|
-
* @param target - destination scrollTop.
|
|
1881
|
-
* @param durationMs - animation budget.
|
|
1882
|
-
*/
|
|
1883
|
-
function animateScrollTop(el, target, durationMs) {
|
|
1884
|
-
return new Promise((resolve) => {
|
|
1885
|
-
const start = el.scrollTop;
|
|
1886
|
-
const delta = target - start;
|
|
1887
|
-
let done = false;
|
|
1888
|
-
let frame = 0;
|
|
1889
|
-
const finish = (completed) => {
|
|
1890
|
-
if (done) return;
|
|
1891
|
-
done = true;
|
|
1892
|
-
el.removeEventListener("wheel", onWheel);
|
|
1893
|
-
el.removeEventListener("touchstart", onTouch);
|
|
1894
|
-
window.removeEventListener("keydown", onKey);
|
|
1895
|
-
if (frame !== 0) cancelAnimationFrame(frame);
|
|
1896
|
-
resolve(completed);
|
|
1897
|
-
};
|
|
1898
|
-
const onWheel = () => finish(false);
|
|
1899
|
-
const onTouch = () => finish(false);
|
|
1900
|
-
const onKey = (e) => {
|
|
1901
|
-
if ([
|
|
1902
|
-
"ArrowUp",
|
|
1903
|
-
"ArrowDown",
|
|
1904
|
-
"PageUp",
|
|
1905
|
-
"PageDown",
|
|
1906
|
-
"Home",
|
|
1907
|
-
"End",
|
|
1908
|
-
" "
|
|
1909
|
-
].includes(e.key)) finish(false);
|
|
1910
|
-
};
|
|
1911
|
-
el.addEventListener("wheel", onWheel, { passive: true });
|
|
1912
|
-
el.addEventListener("touchstart", onTouch, { passive: true });
|
|
1913
|
-
window.addEventListener("keydown", onKey);
|
|
1914
|
-
if (Math.abs(delta) < .5 || durationMs <= 0) {
|
|
1915
|
-
el.scrollTop = target;
|
|
1916
|
-
finish(true);
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1919
|
-
const t0 = performance.now();
|
|
1920
|
-
const ease = (x) => x < .5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
|
|
1921
|
-
const step = (now) => {
|
|
1922
|
-
if (done) return;
|
|
1923
|
-
const t = Math.min(1, (now - t0) / durationMs);
|
|
1924
|
-
el.scrollTop = start + delta * ease(t);
|
|
1925
|
-
if (t < 1) frame = requestAnimationFrame(step);
|
|
1926
|
-
else finish(true);
|
|
1927
|
-
};
|
|
1928
|
-
frame = requestAnimationFrame(step);
|
|
1929
|
-
});
|
|
1930
|
-
}
|
|
1931
|
-
/**
|
|
1932
|
-
* Page older history in WITHOUT moving what the reader is looking at.
|
|
1933
|
-
*
|
|
1934
|
-
* The host's ChatView compensates a prepend through an internal anchor ref
|
|
1935
|
-
* that only its own "load earlier" button and its own navigation arm. A
|
|
1936
|
-
* plugin calling `session.loadOlder()` from the outside gets no compensation,
|
|
1937
|
-
* so the prepended rows push the reader's content down — a visible jump.
|
|
1938
|
-
* This restores the visual position by the growth the prepend added.
|
|
1939
|
-
*
|
|
1940
|
-
* A reader pinned to the floor needs none of it: the host's follow logic
|
|
1941
|
-
* re-pins to the (growing) floor by itself, and compensating there would
|
|
1942
|
-
* fight it and overshoot.
|
|
1943
|
-
*
|
|
1944
|
-
* @param session - the session handle to page.
|
|
1945
|
-
* @param el - the conversation scrollport.
|
|
1946
|
-
* @param followZonePx - the host's bottom-follow threshold.
|
|
1947
|
-
* @returns whether the page actually grew the transcript (false = exhausted
|
|
1948
|
-
* or failed, so the caller should stop paging).
|
|
1949
|
-
*/
|
|
1950
|
-
async function compensatedLoadOlder(session, el, followZonePx = 25) {
|
|
1951
|
-
const pinned = scrollFloorOf(el) - el.scrollTop <= followZonePx;
|
|
1952
|
-
const heightBefore = el.scrollHeight;
|
|
1953
|
-
try {
|
|
1954
|
-
await session.loadOlder();
|
|
1955
|
-
} catch (error) {
|
|
1956
|
-
console.warn("[deepseek-harness-background] timeline history page failed:", error);
|
|
1957
|
-
return false;
|
|
1958
|
-
}
|
|
1959
|
-
for (let round = 0; round < WARM_PAGE_SETTLE_ROUNDS; round += 1) {
|
|
1960
|
-
await settleScrollEvents();
|
|
1961
|
-
const growth = el.scrollHeight - heightBefore;
|
|
1962
|
-
if (growth > 0) {
|
|
1963
|
-
if (!pinned) el.scrollTop = el.scrollTop + growth;
|
|
1964
|
-
return true;
|
|
1965
|
-
}
|
|
1966
|
-
if (round < 7) await delay(WARM_PAGE_SETTLE_MS);
|
|
1967
|
-
}
|
|
1968
|
-
return false;
|
|
1969
|
-
}
|
|
1970
|
-
/**
|
|
1971
|
-
* Page older history in until the rail is full or the log is exhausted — the
|
|
1972
|
-
* "turns behind the load-earlier button become reachable" half of the
|
|
1973
|
-
* enhancement, shared by both frontends.
|
|
1974
|
-
*
|
|
1975
|
-
* Stops at `capacity` on purpose: past the rail's uncompressed tick count the
|
|
1976
|
-
* official stylesheet squeezes every mark into the same 420px column and the
|
|
1977
|
-
* rail degrades into a solid bar the reader cannot aim at. Filling it exactly
|
|
1978
|
-
* to capacity is free reach; going past it is not.
|
|
1979
|
-
*
|
|
1980
|
-
* @param session - the session handle to page.
|
|
1981
|
-
* @param scrollport - the conversation scrollport (compensation anchor).
|
|
1982
|
-
* @param options - budgets, the live entry count and the rail's capacity.
|
|
1983
|
-
*/
|
|
1984
|
-
async function warmHistory(session, scrollport, options) {
|
|
1985
|
-
const followZonePx = options.followZonePx ?? 25;
|
|
1986
|
-
const deadline = Date.now() + (options.deadlineMs ?? WARM_DEADLINE_MS);
|
|
1987
|
-
const maxPages = options.maxPages ?? WARM_MAX_PAGES;
|
|
1988
|
-
const alive = options.alive ?? (() => true);
|
|
1989
|
-
for (let page = 0; page < maxPages; page += 1) {
|
|
1990
|
-
if (!alive()) return;
|
|
1991
|
-
if (options.countOf() >= options.capacity) return;
|
|
1992
|
-
const snapshot = session.getSnapshot();
|
|
1993
|
-
if (snapshot?.hasMore !== true) return;
|
|
1994
|
-
if (Date.now() >= deadline) return;
|
|
1995
|
-
if (snapshot.loadingOlder === true) {
|
|
1996
|
-
await delay(50);
|
|
1997
|
-
continue;
|
|
1998
|
-
}
|
|
1999
|
-
if (snapshot.openState !== void 0 && snapshot.openState !== "open") {
|
|
2000
|
-
await delay(100);
|
|
2001
|
-
continue;
|
|
2002
|
-
}
|
|
2003
|
-
if (!await compensatedLoadOlder(session, scrollport, followZonePx)) return;
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
/**
|
|
2007
|
-
* Ensure the target node is loaded, wait for the chat view to commit its row
|
|
2008
|
-
* into the DOM, then glide it into view. Paging uses a time budget instead
|
|
2009
|
-
* of a fixed iteration guard (each in-flight page burned part of that guard,
|
|
2010
|
-
* so slow history could exhaust it and silently fail).
|
|
2011
|
-
*
|
|
2012
|
-
* @param sessionsService - the runtime sessions service.
|
|
2013
|
-
* @param sessionId - the session owning the target row.
|
|
2014
|
-
* @param anchorKey - chat anchor key of the target row.
|
|
2015
|
-
* @param options - follow zone, landing resolver, paging scopes and budgets.
|
|
2016
|
-
* @returns whether the row was reached (false when it never rendered or the
|
|
2017
|
-
* reader took the scrollport back mid-glide).
|
|
2018
|
-
*/
|
|
2019
|
-
async function jumpToMessage(sessionsService, sessionId, anchorKey, options = {}) {
|
|
2020
|
-
const session = sessionsService.binding(sessionId)?.session;
|
|
2021
|
-
if (session === void 0) return false;
|
|
2022
|
-
const followZonePx = options.followZonePx ?? 25;
|
|
2023
|
-
const resolveTarget = options.targetTop ?? centeredScrollTopFor;
|
|
2024
|
-
const pageDeadlineMs = options.pageDeadlineMs ?? JUMP_PAGE_DEADLINE_MS;
|
|
2025
|
-
const rowWaitMs = options.rowWaitMs ?? JUMP_ROW_WAIT_MS;
|
|
2026
|
-
const ports = options.scrollport != null ? [options.scrollport] : conversationScrollports();
|
|
2027
|
-
let detachedAny = false;
|
|
2028
|
-
for (const el of ports) if (detachBottomFollow(el, followZonePx)) {
|
|
2029
|
-
el.dispatchEvent(new Event("scroll"));
|
|
2030
|
-
detachedAny = true;
|
|
2031
|
-
}
|
|
2032
|
-
if (detachedAny) await settleScrollEvents();
|
|
2033
|
-
const deadline = Date.now() + pageDeadlineMs;
|
|
2034
|
-
for (;;) {
|
|
2035
|
-
if (rowRenderedInDom(anchorKey)) break;
|
|
2036
|
-
const snapshot = session.getSnapshot();
|
|
2037
|
-
const keyedNodeOf = (store) => store !== null && typeof store === "object" && typeof store.get === "function" ? store.get(anchorKey) : void 0;
|
|
2038
|
-
if ((keyedNodeOf(snapshot?.chat?.nodes) ?? keyedNodeOf(snapshot?.nodes)) !== void 0) break;
|
|
2039
|
-
if (snapshot?.hasMore !== true) break;
|
|
2040
|
-
if (Date.now() >= deadline) break;
|
|
2041
|
-
if (snapshot.loadingOlder === true) {
|
|
2042
|
-
await delay(50);
|
|
2043
|
-
continue;
|
|
2044
|
-
}
|
|
2045
|
-
if (snapshot.openState !== void 0 && snapshot.openState !== "open") {
|
|
2046
|
-
await delay(100);
|
|
2047
|
-
continue;
|
|
2048
|
-
}
|
|
2049
|
-
try {
|
|
2050
|
-
await session.loadOlder();
|
|
2051
|
-
} catch (error) {
|
|
2052
|
-
console.warn("[deepseek-harness-background] timeline jump paging failed:", error);
|
|
2053
|
-
break;
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
const row = await waitForChatRow(anchorKey, rowWaitMs);
|
|
2057
|
-
if (row === null) {
|
|
2058
|
-
console.warn(`[deepseek-harness-background] timeline jump target never rendered: ${anchorKey}`);
|
|
2059
|
-
return false;
|
|
2060
|
-
}
|
|
2061
|
-
const reducedMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2062
|
-
const scroller = row.closest("[data-conversation-scroll]");
|
|
2063
|
-
if (scroller === null) {
|
|
2064
|
-
row.scrollIntoView({
|
|
2065
|
-
behavior: "auto",
|
|
2066
|
-
block: "center"
|
|
2067
|
-
});
|
|
2068
|
-
if (!reducedMotion) row.scrollIntoView({
|
|
2069
|
-
behavior: "smooth",
|
|
2070
|
-
block: "center"
|
|
2071
|
-
});
|
|
2072
|
-
return true;
|
|
2073
|
-
}
|
|
2074
|
-
const target = resolveTarget(scroller, row);
|
|
2075
|
-
if (reducedMotion || Math.abs(target - scroller.scrollTop) < MIN_GLIDE_DISTANCE_PX) {
|
|
2076
|
-
scroller.scrollTop = target;
|
|
2077
|
-
return true;
|
|
2078
|
-
}
|
|
2079
|
-
if (detachBottomFollow(scroller, followZonePx)) {
|
|
2080
|
-
scroller.dispatchEvent(new Event("scroll"));
|
|
2081
|
-
await settleScrollEvents();
|
|
2082
|
-
}
|
|
2083
|
-
return await animateScrollTop(scroller, target, glideDurationFor(Math.abs(target - scroller.scrollTop)));
|
|
2084
|
-
}
|
|
2085
|
-
/** The official stylesheet's rail height cap (TurnNavigator.module.css). */
|
|
2086
|
-
const RAIL_HEIGHT_CAP_PX = 420;
|
|
2087
|
-
/** Band clearance the official stylesheet reserves around the rail. */
|
|
2088
|
-
const RAIL_BAND_CLEARANCE_PX = 64;
|
|
2089
|
-
/**
|
|
2090
|
-
* The rail's own inset, read off the custom property the official component
|
|
2091
|
-
* publishes (`--turn-rail-inset`). Falls back to the official constant.
|
|
2092
|
-
* @param rail - the rail element (or null).
|
|
2093
|
-
*/
|
|
2094
|
-
function railInsetOf(rail) {
|
|
2095
|
-
if (rail === null) return 6;
|
|
2096
|
-
const px = Number.parseFloat(rail.style.getPropertyValue("--turn-rail-inset"));
|
|
2097
|
-
return Number.isFinite(px) && px >= 0 ? px : 6;
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* The tallest the rail can render. The official stylesheet clamps the rail's
|
|
2101
|
-
* height with `min(natural, band - 64px, 420px)` and the band measurements
|
|
2102
|
-
* are published inline on the scroll host (ConversationRoot's
|
|
2103
|
-
* `--dsh-conversation-viewport-height` / `--dsh-composer-height`). Outside
|
|
2104
|
-
* the official layout — the ported rail portals itself to body, and its own
|
|
2105
|
-
* stylesheet carries the same 420px cap — only the cap applies.
|
|
2106
|
-
*
|
|
2107
|
-
* The rendered `clientHeight` must NOT be used here: with few loaded turns
|
|
2108
|
-
* the height IS the natural (count-driven) height, so a measured capacity
|
|
2109
|
-
* would equal the count and a "rail full" gate could never open — the
|
|
2110
|
-
* warm-up fixed point that left early turns unreachable.
|
|
2111
|
-
*/
|
|
2112
|
-
function railMaxHeightOf(rail) {
|
|
2113
|
-
const scrollport = rail.closest("[data-conversation-scroll]");
|
|
2114
|
-
if (scrollport !== null) {
|
|
2115
|
-
const viewport = Number.parseFloat(scrollport.style.getPropertyValue("--dsh-conversation-viewport-height"));
|
|
2116
|
-
const composer = Number.parseFloat(scrollport.style.getPropertyValue("--dsh-composer-height"));
|
|
2117
|
-
if (Number.isFinite(viewport) && Number.isFinite(composer)) return Math.min(RAIL_HEIGHT_CAP_PX, Math.max(0, viewport - composer - RAIL_BAND_CLEARANCE_PX));
|
|
2118
|
-
}
|
|
2119
|
-
return RAIL_HEIGHT_CAP_PX;
|
|
2120
|
-
}
|
|
2121
|
-
/**
|
|
2122
|
-
* How many marks the rail can show before it starts compressing them.
|
|
2123
|
-
*
|
|
2124
|
-
* Past this count the official stylesheet clamps the rail's height and every
|
|
2125
|
-
* mark switches from its fixed 10px spacing to a percentage position, so the
|
|
2126
|
-
* column degrades into a solid bar the reader cannot aim at. Filling the rail
|
|
2127
|
-
* exactly to this capacity is therefore free extra reach; going past it is
|
|
2128
|
-
* not.
|
|
2129
|
-
*
|
|
2130
|
-
* @param rail - the rail element (or null).
|
|
2131
|
-
*/
|
|
2132
|
-
function railCapacityOf(rail) {
|
|
2133
|
-
if (rail === null) return 41;
|
|
2134
|
-
const usable = railMaxHeightOf(rail) - 2 * railInsetOf(rail);
|
|
2135
|
-
if (!Number.isFinite(usable)) return 41;
|
|
2136
|
-
return Math.max(2, Math.floor(usable / 10) + 1);
|
|
2137
|
-
}
|
|
2138
|
-
/**
|
|
2139
|
-
* Resolve the entry under a pointer Y on the COMPRESSING rail — the official
|
|
2140
|
-
* alpha.1/2 geometry verbatim (`itemAtPointer` in that generation's
|
|
2141
|
-
* TurnNavigator.tsx), so this plugin lands on exactly the mark the official
|
|
2142
|
-
* rail would have picked. The legacy port (whose own rail never scrolls
|
|
2143
|
-
* internally) uses the same mapping, where ratio and fixed pitch coincide.
|
|
2144
|
-
* @param count - number of entries the rail shows.
|
|
2145
|
-
* @param rail - the rail element.
|
|
2146
|
-
* @param clientY - pointer viewport Y.
|
|
2147
|
-
*/
|
|
2148
|
-
function indexAtPointer(count, rail, clientY) {
|
|
2149
|
-
const rect = rail.getBoundingClientRect();
|
|
2150
|
-
const inset = railInsetOf(rail);
|
|
2151
|
-
const usableHeight = Math.max(1, rect.height - 2 * inset);
|
|
2152
|
-
const ratio = Math.max(0, Math.min(1, (clientY - rect.top - inset) / usableHeight));
|
|
2153
|
-
return Math.round(ratio * (count - 1));
|
|
2154
|
-
}
|
|
2155
|
-
/**
|
|
2156
|
-
* Whether `rail` is the frame-style rail (dsh >= 0.1.2-alpha.3): marks sit
|
|
2157
|
-
* at a fixed pitch and scroll INSIDE the frame, whose scroller offset the
|
|
2158
|
-
* component publishes inline as `--turn-scroll-top` — every render, resting
|
|
2159
|
-
* at `0px` included. The compressing alpha.1/2 rail never publishes that
|
|
2160
|
-
* property, so its presence is the generation test. The legacy port paints
|
|
2161
|
-
* its own `--dsbt-*` properties and can never trip this.
|
|
2162
|
-
* @param rail - the rail element (or null).
|
|
2163
|
-
*/
|
|
2164
|
-
function isFrameRail(rail) {
|
|
2165
|
-
return rail !== null && rail.style.getPropertyValue("--turn-scroll-top") !== "";
|
|
2166
|
-
}
|
|
2167
|
-
/**
|
|
2168
|
-
* Resolve the entry under a pointer Y on the frame-style rail — the official
|
|
2169
|
-
* alpha.3 geometry verbatim (`itemAtPointer` in that generation's
|
|
2170
|
-
* TurnNavigator.tsx): marks live in the scrolled ladder at a fixed 10px
|
|
2171
|
-
* pitch, so the click offset adds the scroller offset. Read off the published
|
|
2172
|
-
* `--turn-scroll-top` var rather than the scroller element itself — the same
|
|
2173
|
-
* metric the official preview positioning uses, so any one-frame render lag
|
|
2174
|
-
* affects both identically.
|
|
2175
|
-
* @param count - number of entries the rail shows.
|
|
2176
|
-
* @param rail - the rail element.
|
|
2177
|
-
* @param clientY - pointer viewport Y.
|
|
2178
|
-
*/
|
|
2179
|
-
function frameIndexAtPointer(count, rail, clientY) {
|
|
2180
|
-
const rect = rail.getBoundingClientRect();
|
|
2181
|
-
const inset = railInsetOf(rail);
|
|
2182
|
-
const raw = Number.parseFloat(rail.style.getPropertyValue("--turn-scroll-top"));
|
|
2183
|
-
const scrollTop = Number.isFinite(raw) ? raw : 0;
|
|
2184
|
-
const offset = clientY - rect.top + scrollTop - inset;
|
|
2185
|
-
return Math.max(0, Math.min(count - 1, Math.round(offset / 10)));
|
|
2186
|
-
}
|
|
2187
|
-
/**
|
|
2188
|
-
* Resolve the gesture that produced a rail click to an entry index.
|
|
2189
|
-
*
|
|
2190
|
-
* Keyboard activation targets the focused mark button (recovered by its
|
|
2191
|
-
* position among the rail's buttons); a mouse click carries coordinates and
|
|
2192
|
-
* resolves through the rail geometry — the frame-style alpha.3 mapping when
|
|
2193
|
-
* the rail publishes `--turn-scroll-top`, the compressing alpha.1/2 mapping
|
|
2194
|
-
* otherwise. Returns -1 when neither applies.
|
|
2195
|
-
*
|
|
2196
|
-
* Assumption (both frontends): the mark ticks are the ONLY buttons under the
|
|
2197
|
-
* rail — the official rail renders marks exclusively and its preview tooltip
|
|
2198
|
-
* carries none, and the ported rail does the same. A future non-mark control
|
|
2199
|
-
* added inside the rail would shift the button index and must revisit this.
|
|
2200
|
-
*
|
|
2201
|
-
* @param rail - the rail element.
|
|
2202
|
-
* @param event - the click event.
|
|
2203
|
-
*/
|
|
2204
|
-
function indexForEvent(rail, event) {
|
|
2205
|
-
const target = event.target;
|
|
2206
|
-
if (target instanceof Element) {
|
|
2207
|
-
const button = target.closest("button");
|
|
2208
|
-
if (button !== null && rail.contains(button)) {
|
|
2209
|
-
const index = Array.from(rail.querySelectorAll("button")).indexOf(button);
|
|
2210
|
-
if (index >= 0) return index;
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
const count = rail.querySelectorAll("button").length;
|
|
2214
|
-
if (count === 0) return -1;
|
|
2215
|
-
return isFrameRail(rail) ? frameIndexAtPointer(count, rail, event.clientY) : indexAtPointer(count, rail, event.clientY);
|
|
2216
|
-
}
|
|
2217
|
-
/**
|
|
2218
|
-
* Validate the official navigation array crossing the slot boundary. The
|
|
2219
|
-
* value is produced by the kernel and may be absent, partial, or from an
|
|
2220
|
-
* older build, so every field is checked before use.
|
|
2221
|
-
* @param value - the raw `useChat(s => s.navigation.items())` result.
|
|
2222
|
-
*/
|
|
2223
|
-
function normalizeNavigationItems(value) {
|
|
2224
|
-
if (!Array.isArray(value)) return [];
|
|
2225
|
-
const out = [];
|
|
2226
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2227
|
-
for (const item of value) {
|
|
2228
|
-
if (item === null || typeof item !== "object") continue;
|
|
2229
|
-
const rec = item;
|
|
2230
|
-
if (typeof rec.turn !== "number" || !Number.isFinite(rec.turn)) continue;
|
|
2231
|
-
if (typeof rec.anchorKey !== "string" || rec.anchorKey === "") continue;
|
|
2232
|
-
if (seen.has(rec.turn)) continue;
|
|
2233
|
-
seen.add(rec.turn);
|
|
2234
|
-
out.push({
|
|
2235
|
-
turn: rec.turn,
|
|
2236
|
-
anchorKey: rec.anchorKey,
|
|
2237
|
-
prompt: typeof rec.prompt === "string" ? rec.prompt : "",
|
|
2238
|
-
response: typeof rec.response === "string" ? rec.response : ""
|
|
2239
|
-
});
|
|
2240
|
-
}
|
|
2241
|
-
return out;
|
|
2242
|
-
}
|
|
2243
|
-
/**
|
|
2244
|
-
* Validate one `turnOutline` wire value into its entries. The value is the
|
|
2245
|
-
* host projection's wire view (an array of `{ turn, seq, prompt, response }`
|
|
2246
|
-
* — see `dsh-session-turn-outline`) read through `useProjection`, so it may
|
|
2247
|
-
* be absent (pre-baseline, or an older kernel that never registers the key —
|
|
2248
|
-
* which reads `undefined`), partial, or damaged. `turn` and `seq` are the
|
|
2249
|
-
* load-bearing fields — a mark cannot exist or page without them — and a
|
|
2250
|
-
* damaged one drops the entry; the previews are decorative and degrade to ''.
|
|
2251
|
-
* @param value - the raw `useProjection('turnOutline')` value.
|
|
2252
|
-
*/
|
|
2253
|
-
function normalizeOutlineItems(value) {
|
|
2254
|
-
if (!Array.isArray(value)) return [];
|
|
2255
|
-
const out = [];
|
|
2256
|
-
for (const item of value) {
|
|
2257
|
-
if (item === null || typeof item !== "object") continue;
|
|
2258
|
-
const rec = item;
|
|
2259
|
-
if (typeof rec.turn !== "number" || !Number.isSafeInteger(rec.turn) || rec.turn < 0) continue;
|
|
2260
|
-
if (typeof rec.seq !== "number" || !Number.isSafeInteger(rec.seq) || rec.seq < 0) continue;
|
|
2261
|
-
out.push({
|
|
2262
|
-
turn: rec.turn,
|
|
2263
|
-
seq: rec.seq,
|
|
2264
|
-
prompt: typeof rec.prompt === "string" ? rec.prompt : "",
|
|
2265
|
-
response: typeof rec.response === "string" ? rec.response : ""
|
|
2266
|
-
});
|
|
2267
|
-
}
|
|
2268
|
-
return out;
|
|
2269
|
-
}
|
|
2270
|
-
/**
|
|
2271
|
-
* Rebuild the rail's full mark ladder exactly as the official ChatView does
|
|
2272
|
-
* (a mirror of `mergeTurnRailItems` in dsh-client-ui-chat): outline entries
|
|
2273
|
-
* seed every known turn as unloaded, loaded items then override with their
|
|
2274
|
-
* anchor key and richer previews, ascending by turn. The enhancer maps a
|
|
2275
|
-
* click to a mark by INDEX, so its ladder must match the RENDERED marks —
|
|
2276
|
-
* which are exactly this merge on an alpha.3 kernel; without the outline
|
|
2277
|
-
* value the merge degrades to the loaded items (the alpha.1/2 ladder).
|
|
2278
|
-
* @param loaded - the validated loaded-window items.
|
|
2279
|
-
* @param outline - the raw turnOutline wire value.
|
|
2280
|
-
*/
|
|
2281
|
-
function mergeRailItems(loaded, outline) {
|
|
2282
|
-
const byTurn = /* @__PURE__ */ new Map();
|
|
2283
|
-
for (const entry of normalizeOutlineItems(outline)) byTurn.set(entry.turn, {
|
|
2284
|
-
turn: entry.turn,
|
|
2285
|
-
prompt: entry.prompt,
|
|
2286
|
-
response: entry.response
|
|
2287
|
-
});
|
|
2288
|
-
for (const item of loaded) {
|
|
2289
|
-
const preview = byTurn.get(item.turn);
|
|
2290
|
-
byTurn.set(item.turn, {
|
|
2291
|
-
turn: item.turn,
|
|
2292
|
-
prompt: item.prompt !== "" ? item.prompt : preview?.prompt ?? "",
|
|
2293
|
-
response: item.response !== "" ? item.response : preview?.response ?? "",
|
|
2294
|
-
anchorKey: item.anchorKey
|
|
2295
|
-
});
|
|
2296
|
-
}
|
|
2297
|
-
if (byTurn.size === 0) return [];
|
|
2298
|
-
return [...byTurn.values()].sort((left, right) => left.turn - right.turn);
|
|
2299
|
-
}
|
|
2300
|
-
//#endregion
|
|
2301
|
-
//#region src/client/timeline/source.ts
|
|
2302
|
-
/**
|
|
2303
|
-
* Enumerate the chat nodes of one conversation snapshot, duck-typed across
|
|
2304
|
-
* both kernel generations:
|
|
2305
|
-
* - dsh <= 0.1.1: `snapshot.chat.nodes` is a `Map` keyed by node key.
|
|
2306
|
-
* - dsh >= 0.1.2: the Chat snapshot is `{ order, nodes: { values() } }` and
|
|
2307
|
-
* the session snapshot has no `chat` field at all.
|
|
2308
|
-
*
|
|
2309
|
-
* Both stores expose `values()`, so one structural read covers both; the
|
|
2310
|
-
* receiver must survive the call because these are prototype methods (the
|
|
2311
|
-
* real Session's own methods read `this`, and so do Map's).
|
|
2312
|
-
*
|
|
2313
|
-
* @param snapshot - a session snapshot or a Chat snapshot (loosely typed).
|
|
2314
|
-
* @returns the nodes, in the store's own order; empty when neither shape fits.
|
|
2315
|
-
*/
|
|
2316
|
-
function chatNodesOf(snapshot) {
|
|
2317
|
-
const valuesOf = (store) => {
|
|
2318
|
-
if (store === null || typeof store !== "object") return void 0;
|
|
2319
|
-
const values = store.values;
|
|
2320
|
-
if (typeof values !== "function") return void 0;
|
|
2321
|
-
let iterable;
|
|
2322
|
-
try {
|
|
2323
|
-
iterable = values.call(store);
|
|
2324
|
-
} catch {
|
|
2325
|
-
return;
|
|
2326
|
-
}
|
|
2327
|
-
if (iterable === null || typeof iterable !== "object") return void 0;
|
|
2328
|
-
const out = [];
|
|
2329
|
-
for (const node of iterable) if (node !== null && typeof node === "object") out.push(node);
|
|
2330
|
-
return out;
|
|
2331
|
-
};
|
|
2332
|
-
const rec = snapshot ?? {};
|
|
2333
|
-
return valuesOf(rec.chat?.nodes) ?? valuesOf(rec.nodes) ?? [];
|
|
2334
|
-
}
|
|
2335
|
-
/** Is this node part of the visible surface? Nodes without the flag are legacy. */
|
|
2336
|
-
function isVisible(node) {
|
|
2337
|
-
return node.visibility === void 0 || node.visibility === "visible";
|
|
2338
|
-
}
|
|
2339
|
-
/** Preview cap so entries stay small (one short line per row). */
|
|
2340
|
-
const MAX_TEXT_CHARS = 80;
|
|
2341
|
-
/** Extract preview text from a user message's ContentBlock list. */
|
|
2342
|
-
function userTextOf(content) {
|
|
2343
|
-
if (!Array.isArray(content)) return "";
|
|
2344
|
-
let out = "";
|
|
2345
|
-
for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
|
|
2346
|
-
const text = block.text;
|
|
2347
|
-
if (typeof text === "string") out += text;
|
|
2348
|
-
}
|
|
2349
|
-
return out.trim().slice(0, MAX_TEXT_CHARS);
|
|
2350
|
-
}
|
|
2351
|
-
/** Parse digits into a finite number (undefined otherwise). */
|
|
2352
|
-
function parseSeqDigits(digits) {
|
|
2353
|
-
if (digits === void 0) return void 0;
|
|
2354
|
-
const n = Number.parseInt(digits, 10);
|
|
2355
|
-
return Number.isFinite(n) ? n : void 0;
|
|
2356
|
-
}
|
|
2357
|
-
/** Extract a seq from a string-ish field ('@42', 'seq 42', '#42', bare 42). */
|
|
2358
|
-
function seqFromStringish(value) {
|
|
2359
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2360
|
-
if (typeof value !== "string") return void 0;
|
|
2361
|
-
const m = /(?:@|seq\s*|#)?(\d+)/i.exec(value);
|
|
2362
|
-
return m === null ? void 0 : parseSeqDigits(m[1]);
|
|
2363
|
-
}
|
|
2364
|
-
/**
|
|
2365
|
-
* Extract the rewind target seq from a rewind outcome text. Multi-pattern:
|
|
2366
|
-
* an explicit `seq 42` declaration wins, then `#42`, then the broad
|
|
2367
|
-
* english/chinese verb forms — a single "rewound" regex silently missed real
|
|
2368
|
-
* success texts like "已撤回 seq 42" / "Withdrawn seq 42".
|
|
2369
|
-
* @param text - the command outcome text.
|
|
2370
|
-
*/
|
|
2371
|
-
function rewindTargetOfOutcome(text) {
|
|
2372
|
-
if (typeof text !== "string") return void 0;
|
|
2373
|
-
const seqMatch = /seq\s*(\d+)/i.exec(text);
|
|
2374
|
-
if (seqMatch !== null) return parseSeqDigits(seqMatch[1]);
|
|
2375
|
-
const hashMatch = /#(\d+)/.exec(text);
|
|
2376
|
-
if (hashMatch !== null) return parseSeqDigits(hashMatch[1]);
|
|
2377
|
-
const broadMatch = /(?:rewound|withdrawn|已撤回).*?(?:target\s+)?(\d+)/i.exec(text);
|
|
2378
|
-
if (broadMatch !== null) return parseSeqDigits(broadMatch[1]);
|
|
2379
|
-
}
|
|
2380
|
-
/**
|
|
2381
|
-
* Resolve the target of a rewind command: structured fields first
|
|
2382
|
-
* (outcome.targetSeq / outcome.target / args.targetSeq / args.target /
|
|
2383
|
-
* args.seq / args.raw / args[0]), then the outcome text — so
|
|
2384
|
-
* locale-independent producers keep parsing without text heuristics (the
|
|
2385
|
-
* args.seq guard skips the command's own seq).
|
|
2386
|
-
* @param command - the command payload.
|
|
2387
|
-
*/
|
|
2388
|
-
function rewindTargetOfCommand(command) {
|
|
2389
|
-
if (command === null || typeof command !== "object") return void 0;
|
|
2390
|
-
const rec = command;
|
|
2391
|
-
const outcome = rec.outcome;
|
|
2392
|
-
if (outcome !== null && typeof outcome === "object") {
|
|
2393
|
-
const o = outcome;
|
|
2394
|
-
if (typeof o.targetSeq === "number" && Number.isFinite(o.targetSeq)) return o.targetSeq;
|
|
2395
|
-
if (typeof o.target === "number" && Number.isFinite(o.target)) return o.target;
|
|
2396
|
-
}
|
|
2397
|
-
const args = rec.args;
|
|
2398
|
-
if (args !== null && typeof args === "object") {
|
|
2399
|
-
const record = args;
|
|
2400
|
-
if (typeof record.targetSeq === "number" && Number.isFinite(record.targetSeq)) return record.targetSeq;
|
|
2401
|
-
if (typeof record.target === "number" && Number.isFinite(record.target)) return record.target;
|
|
2402
|
-
if (typeof record.seq === "number" && Number.isFinite(record.seq) && record.seq !== rec.seq) return record.seq;
|
|
2403
|
-
const rawish = record.raw ?? record["0"] ?? (Array.isArray(args) ? args[0] : void 0);
|
|
2404
|
-
if (rawish !== void 0) {
|
|
2405
|
-
const n = seqFromStringish(rawish);
|
|
2406
|
-
if (n !== void 0) return n;
|
|
2407
|
-
}
|
|
2408
|
-
if (typeof record.target === "string") {
|
|
2409
|
-
const n = seqFromStringish(record.target);
|
|
2410
|
-
if (n !== void 0) return n;
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
if (outcome !== null && typeof outcome === "object") {
|
|
2414
|
-
const text = outcome.text;
|
|
2415
|
-
if (typeof text === "string") return rewindTargetOfOutcome(text);
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2418
|
-
/**
|
|
2419
|
-
* Is this rewind command a preview/dry-run (marks the target hidden without
|
|
2420
|
-
* an outcome)?
|
|
2421
|
-
* @param command - the command payload.
|
|
2422
|
-
*/
|
|
2423
|
-
function isRewindPreviewCommand(command) {
|
|
2424
|
-
if (command.args === null || typeof command.args !== "object") return false;
|
|
2425
|
-
const args = command.args;
|
|
2426
|
-
return args.preview === true || args.dryRun === true;
|
|
2427
|
-
}
|
|
2428
|
-
/**
|
|
2429
|
-
* Seq numbers of nodes carrying a rewind-hidden marker: host/plugin producers
|
|
2430
|
-
* can tag the node record itself (rewindHidden), its data payload, or the
|
|
2431
|
-
* payload's attributes map.
|
|
2432
|
-
* @param chat - a chat snapshot (loosely typed on purpose).
|
|
2433
|
-
*/
|
|
2434
|
-
function rewindMarkedSeqsOfChat(chat) {
|
|
2435
|
-
const marked = /* @__PURE__ */ new Set();
|
|
2436
|
-
for (const node of chatNodesOf(chat)) {
|
|
2437
|
-
const data = node.data;
|
|
2438
|
-
if (data === null || typeof data !== "object") continue;
|
|
2439
|
-
const record = data;
|
|
2440
|
-
const attrs = record.attributes;
|
|
2441
|
-
if (attrs !== null && typeof attrs === "object" && attrs["data-dsh-rewind-hidden"] === true) {
|
|
2442
|
-
if (typeof node.anchorSeq === "number") marked.add(node.anchorSeq);
|
|
2443
|
-
continue;
|
|
2444
|
-
}
|
|
2445
|
-
if (record["data-dsh-rewind-hidden"] === true || node.rewindHidden === true) {
|
|
2446
|
-
if (typeof node.anchorSeq === "number") marked.add(node.anchorSeq);
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
return marked;
|
|
2450
|
-
}
|
|
2451
|
-
/**
|
|
2452
|
-
* Seq numbers a rewind hid directly: successful commands hide their own row;
|
|
2453
|
-
* preview commands hide their target.
|
|
2454
|
-
* @param chat - a chat snapshot (loosely typed on purpose).
|
|
2455
|
-
*/
|
|
2456
|
-
function rewindHiddenSeqsOfChat(chat) {
|
|
2457
|
-
const hidden = /* @__PURE__ */ new Set();
|
|
2458
|
-
if (chat === null || typeof chat !== "object") return hidden;
|
|
2459
|
-
for (const node of chatNodesOf(chat)) {
|
|
2460
|
-
if (node.kind !== "command") continue;
|
|
2461
|
-
const command = node.data;
|
|
2462
|
-
if (command === null || typeof command !== "object") continue;
|
|
2463
|
-
if (command.name !== "rewind") continue;
|
|
2464
|
-
if (isRewindPreviewCommand(command)) {
|
|
2465
|
-
if (typeof command.seq === "number") hidden.add(command.seq);
|
|
2466
|
-
continue;
|
|
2467
|
-
}
|
|
2468
|
-
const outcome = command.outcome;
|
|
2469
|
-
if (outcome === null || typeof outcome !== "object" || outcome.kind !== "success") continue;
|
|
2470
|
-
const own = command.seq;
|
|
2471
|
-
if (typeof own === "number") hidden.add(own);
|
|
2472
|
-
}
|
|
2473
|
-
return hidden;
|
|
2474
|
-
}
|
|
2475
|
-
/**
|
|
2476
|
-
* The [target, sourceEventSeq] spans successful rewind commands cut — the
|
|
2477
|
-
* range form of hiding, needed to filter sources that are NOT chat nodes
|
|
2478
|
-
* (the host projection) by plain seq membership.
|
|
2479
|
-
* @param chat - a chat snapshot (loosely typed on purpose).
|
|
2480
|
-
*/
|
|
2481
|
-
function rewindSpansOfChat(chat) {
|
|
2482
|
-
const spans = [];
|
|
2483
|
-
if (chat === null || typeof chat !== "object") return spans;
|
|
2484
|
-
for (const node of chatNodesOf(chat)) {
|
|
2485
|
-
if (node.kind !== "command") continue;
|
|
2486
|
-
const command = node.data;
|
|
2487
|
-
if (command === null || typeof command !== "object") continue;
|
|
2488
|
-
if (command.name !== "rewind") continue;
|
|
2489
|
-
if (isRewindPreviewCommand(command)) continue;
|
|
2490
|
-
const outcome = command.outcome;
|
|
2491
|
-
if (outcome === null || typeof outcome !== "object") continue;
|
|
2492
|
-
const record = outcome;
|
|
2493
|
-
if (record.kind !== "success") continue;
|
|
2494
|
-
const marker = record.sourceEventSeq;
|
|
2495
|
-
if (typeof marker !== "number") continue;
|
|
2496
|
-
const target = rewindTargetOfCommand(command);
|
|
2497
|
-
if (target !== void 0) spans.push({
|
|
2498
|
-
start: target,
|
|
2499
|
-
end: marker
|
|
2500
|
-
});
|
|
2501
|
-
}
|
|
2502
|
-
return spans;
|
|
2503
|
-
}
|
|
2504
|
-
/**
|
|
2505
|
-
* Collect the seq numbers a rewind hid: preview commands hide their target
|
|
2506
|
-
* directly; successful commands hide their own row plus every node anchored
|
|
2507
|
-
* inside the rewound span [target, sourceEventSeq].
|
|
2508
|
-
* @param chat - a chat snapshot (loosely typed on purpose).
|
|
2509
|
-
*/
|
|
2510
|
-
function hiddenSeqsOfChat(chat) {
|
|
2511
|
-
const hidden = rewindHiddenSeqsOfChat(chat);
|
|
2512
|
-
for (const seq of rewindMarkedSeqsOfChat(chat)) hidden.add(seq);
|
|
2513
|
-
const spans = rewindSpansOfChat(chat);
|
|
2514
|
-
if (spans.length === 0) return hidden;
|
|
2515
|
-
for (const node of chatNodesOf(chat)) {
|
|
2516
|
-
const anchor = typeof node.anchorSeq === "number" ? node.anchorSeq : void 0;
|
|
2517
|
-
if (anchor === void 0) continue;
|
|
2518
|
-
if (spans.some((span) => anchor >= span.start && anchor <= span.end)) hidden.add(anchor);
|
|
2519
|
-
}
|
|
2520
|
-
return hidden;
|
|
2521
|
-
}
|
|
2522
|
-
/** The user-message node definition kind in ui-conversation. */
|
|
2523
|
-
const INPUT_MESSAGE_KIND = "input-message";
|
|
2524
|
-
/**
|
|
2525
|
-
* Rebuild a chat row's DOM anchor key from the durable message id — the same
|
|
2526
|
-
* engine formula as conversationContextKey(kind, id) for the input-message
|
|
2527
|
-
* definition (`13:input-message<id>`), so projected entries can jump even
|
|
2528
|
-
* when their chat node is not loaded yet.
|
|
2529
|
-
* @param id - the durable message id carried by the projection entry.
|
|
2530
|
-
*/
|
|
2531
|
-
function inputAnchorKeyOf(id) {
|
|
2532
|
-
return `13:${INPUT_MESSAGE_KIND}${id}`;
|
|
2533
|
-
}
|
|
2534
|
-
/**
|
|
2535
|
-
* Validate one projected wire value into rail entries (defensive: the value
|
|
2536
|
-
* crosses the wire and may be absent, partial, or stale).
|
|
2537
|
-
* @param value - the raw useProjection(TIMELINE_PROJECTION_KEY) snapshot.
|
|
2538
|
-
*/
|
|
2539
|
-
function normalizeProjectedTimeline(value) {
|
|
2540
|
-
if (value === null || typeof value !== "object") return [];
|
|
2541
|
-
const raw = value.messages;
|
|
2542
|
-
if (!Array.isArray(raw)) return [];
|
|
2543
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2544
|
-
const out = [];
|
|
2545
|
-
for (const item of raw) {
|
|
2546
|
-
if (item === null || typeof item !== "object") continue;
|
|
2547
|
-
const rec = item;
|
|
2548
|
-
if (typeof rec.seq !== "number" || !Number.isFinite(rec.seq)) continue;
|
|
2549
|
-
if (seen.has(rec.seq)) continue;
|
|
2550
|
-
seen.add(rec.seq);
|
|
2551
|
-
out.push({
|
|
2552
|
-
seq: rec.seq,
|
|
2553
|
-
time: typeof rec.time === "number" ? rec.time : 0,
|
|
2554
|
-
text: typeof rec.text === "string" ? rec.text.slice(0, MAX_TEXT_CHARS) : "",
|
|
2555
|
-
...typeof rec.id === "string" && rec.id !== "" ? { anchorKey: inputAnchorKeyOf(rec.id) } : {}
|
|
2556
|
-
});
|
|
2557
|
-
}
|
|
2558
|
-
out.sort((a, b) => a.seq - b.seq);
|
|
2559
|
-
return out;
|
|
2560
|
-
}
|
|
2561
|
-
/**
|
|
2562
|
-
* Enumerate user messages from the loaded chat-node snapshot (the client-side
|
|
2563
|
-
* collector; sorted by anchor sequence, rewind-hidden entries dropped).
|
|
2564
|
-
* @param snapshot - a session snapshot or Chat snapshot (loosely typed).
|
|
2565
|
-
*/
|
|
2566
|
-
function collectMessages(snapshot) {
|
|
2567
|
-
const hidden = hiddenSeqsOfChat(snapshot);
|
|
2568
|
-
const out = [];
|
|
2569
|
-
for (const node of chatNodesOf(snapshot)) {
|
|
2570
|
-
if (node.kind !== "user") continue;
|
|
2571
|
-
if (!isVisible(node)) continue;
|
|
2572
|
-
const data = node.data;
|
|
2573
|
-
if (data === null || typeof data !== "object" || typeof data.time !== "number" || !Array.isArray(data.content)) continue;
|
|
2574
|
-
const anchorKey = typeof node.key === "string" ? node.key : void 0;
|
|
2575
|
-
if (anchorKey === void 0) continue;
|
|
2576
|
-
const seq = typeof node.anchorSeq === "number" ? node.anchorSeq : 0;
|
|
2577
|
-
if (hidden.has(seq)) continue;
|
|
2578
|
-
out.push({
|
|
2579
|
-
seq,
|
|
2580
|
-
time: data.time,
|
|
2581
|
-
text: userTextOf(data.content),
|
|
2582
|
-
anchorKey
|
|
2583
|
-
});
|
|
2584
|
-
}
|
|
2585
|
-
out.sort((a, b) => a.seq - b.seq);
|
|
2586
|
-
return out;
|
|
2587
|
-
}
|
|
2588
|
-
/**
|
|
2589
|
-
* Count the user messages the loaded window holds WITHOUT collecting or
|
|
2590
|
-
* sorting them — the fast gate railMessages uses to skip the merge path on
|
|
2591
|
-
* the streaming hot path (see there).
|
|
2592
|
-
* @param snapshot - the loaded chat snapshot.
|
|
2593
|
-
*/
|
|
2594
|
-
function countWindowUserMessages(snapshot) {
|
|
2595
|
-
let count = 0;
|
|
2596
|
-
for (const node of chatNodesOf(snapshot)) if (node.kind === "user" && isVisible(node)) count += 1;
|
|
2597
|
-
return count;
|
|
2598
|
-
}
|
|
2599
|
-
/**
|
|
2600
|
-
* Rail entries: the host projection (whole-session index) MERGED with the
|
|
2601
|
-
* loaded chat-node window. The projection normally wins outright, but a
|
|
2602
|
-
* projection that lost its baseline (e.g. the plugin was hot-reloaded after
|
|
2603
|
-
* the session's tail page seeded the client store) can miss early entries —
|
|
2604
|
-
* the window fills those gaps whenever it holds them, and window entries
|
|
2605
|
-
* lend their real anchor key to projected entries whose durable id was
|
|
2606
|
-
* absent (early events without one). Locally-known rewind hiding applies to
|
|
2607
|
-
* the merged list so a cut lands instantly, before the next projection push.
|
|
2608
|
-
* @param snapshot - the session conversation snapshot (node window).
|
|
2609
|
-
* @param projected - the raw bgTimeline projection value.
|
|
2610
|
-
*/
|
|
2611
|
-
function railMessages(snapshot, projected) {
|
|
2612
|
-
const projectedMessages = normalizeProjectedTimeline(projected);
|
|
2613
|
-
const hidden = rewindHiddenSeqsOfChat(snapshot);
|
|
2614
|
-
const marked = rewindMarkedSeqsOfChat(snapshot);
|
|
2615
|
-
const spans = rewindSpansOfChat(snapshot);
|
|
2616
|
-
const isHidden = (seq) => hidden.has(seq) || marked.has(seq) || spans.some((span) => seq >= span.start && seq <= span.end);
|
|
2617
|
-
if (projectedMessages.length === 0) return collectMessages(snapshot);
|
|
2618
|
-
const windowUserCount = countWindowUserMessages(snapshot);
|
|
2619
|
-
const projectionLacksKeys = projectedMessages.some((m) => m.anchorKey === void 0);
|
|
2620
|
-
if (windowUserCount <= projectedMessages.length && !projectionLacksKeys) return marked.size === 0 && hidden.size === 0 && spans.length === 0 ? projectedMessages : projectedMessages.filter((m) => !isHidden(m.seq));
|
|
2621
|
-
const windowMessages = collectMessages(snapshot);
|
|
2622
|
-
const bySeq = /* @__PURE__ */ new Map();
|
|
2623
|
-
for (const m of projectedMessages) if (!isHidden(m.seq)) bySeq.set(m.seq, m);
|
|
2624
|
-
for (const m of windowMessages) {
|
|
2625
|
-
if (isHidden(m.seq)) continue;
|
|
2626
|
-
const existing = bySeq.get(m.seq);
|
|
2627
|
-
if (existing === void 0) {
|
|
2628
|
-
bySeq.set(m.seq, m);
|
|
2629
|
-
continue;
|
|
2630
|
-
}
|
|
2631
|
-
if (existing.anchorKey === void 0) bySeq.set(m.seq, {
|
|
2632
|
-
...existing,
|
|
2633
|
-
anchorKey: m.anchorKey,
|
|
2634
|
-
...existing.text === "" && m.text !== "" ? { text: m.text } : {}
|
|
2635
|
-
});
|
|
2636
|
-
}
|
|
2637
|
-
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
2638
|
-
}
|
|
2639
|
-
//#endregion
|
|
2640
|
-
//#region src/client/timeline/legacy-rail-css.ts
|
|
2641
|
-
/**
|
|
2642
|
-
* Styles for the legacy port of the official turn rail (legacy-rail.tsx).
|
|
2643
|
-
*
|
|
2644
|
-
* A faithful port of `TurnNavigator.module.css` from dsh-client-ui-chat, kept
|
|
2645
|
-
* on the plugin's own `dsbt-` prefix because this rail isportaled to body
|
|
2646
|
-
* instead of living inside the chat scrollport. Metrics are the official ones
|
|
2647
|
-
* verbatim (28px column, 10px tick spacing, 6px end inset, 12/18/20px tick
|
|
2648
|
-
* widths, 420px height ceiling, 100px preview, 300px preview width).
|
|
2649
|
-
*
|
|
2650
|
-
* Glass + edge fade (unlike stock chrome): the rail is background plugin
|
|
2651
|
-
* chrome over the user's art, so the hover preview joins the shared glass
|
|
2652
|
-
* recipe under body[data-dsh-bg-glass] (fill = the painter's composer token
|
|
2653
|
-
* + the shared blur/sheen — the same strength as the composer card), and
|
|
2654
|
-
* the marks column gets the DeepSeek-web edge dissolve (top/bottom ticks
|
|
2655
|
-
* fade instead of hard-clipping). The fade is paint-only, always on; the
|
|
2656
|
-
* glass follows the wallpaper/panel-opacity sliders exactly like every
|
|
2657
|
-
* other glassed surface. Both kernel generations render identically.
|
|
2658
|
-
*/
|
|
2659
|
-
/** Unique id stamped on the injected style element (dedup key). */
|
|
2660
|
-
const TIMELINE_CSS_TAG = "deepseek-harness-background/timeline";
|
|
2661
|
-
/** Inject the rail stylesheet once. Idempotent (dedup key on the tag). */
|
|
2662
|
-
function injectTimelineCss() {
|
|
2663
|
-
if (typeof document === "undefined") return;
|
|
2664
|
-
if (document.querySelector(`style[data-plugin-css="deepseek-harness-background/timeline"]`)) return;
|
|
2665
|
-
const tag = document.createElement("style");
|
|
2666
|
-
tag.dataset.plugin = "deepseek-harness-background";
|
|
2667
|
-
tag.dataset.pluginCss = TIMELINE_CSS_TAG;
|
|
2668
|
-
tag.textContent = TIMELINE_CSS;
|
|
2669
|
-
document.head.appendChild(tag);
|
|
2670
|
-
}
|
|
2671
|
-
const TIMELINE_CSS = `
|
|
2672
|
-
/* Portaled host: the official slot is a zero-height sticky box inside the
|
|
2673
|
-
chat scrollport; a fixed box centred on the transcript band is the
|
|
2674
|
-
equivalent outside it. */
|
|
2675
|
-
.dsbt-slot {
|
|
2676
|
-
position: fixed; top: 50%; right: 16px; transform: translateY(-50%);
|
|
2677
|
-
z-index: 6; display: flex; align-items: center; pointer-events: none;
|
|
2678
|
-
}
|
|
2679
|
-
@media (max-width: 767px) { .dsbt-slot { display: none; } }
|
|
2680
|
-
|
|
2681
|
-
.dsbt-rail {
|
|
2682
|
-
position: relative; width: 28px; cursor: pointer; pointer-events: auto;
|
|
2683
|
-
transition: height 220ms cubic-bezier(.2, .8, .2, 1);
|
|
2684
|
-
height: min(var(--dsbt-natural-h, 12px), var(--dsbt-band, 420px), 420px);
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
.dsbt-marks {
|
|
2688
|
-
position: absolute; inset: var(--dsbt-inset, 6px) 0;
|
|
2689
|
-
/* No edge dissolve here: the first and last tick dashes straddle the
|
|
2690
|
-
marks box edges by 1px, so any fade zone sized off the box height
|
|
2691
|
-
swallows them (at a 10-turn rail the 8% recipe rendered the ends at
|
|
2692
|
-
~14% opacity). The end ticks must stay fully painted. */
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
.dsbt-markPosition {
|
|
2696
|
-
position: absolute; top: min(var(--dsbt-natural, 0px), var(--dsbt-ratio, 0%));
|
|
2697
|
-
right: 0; left: 0; height: 10px; transform: translateY(-50%);
|
|
2698
|
-
transition: top 220ms cubic-bezier(.2, .8, .2, 1);
|
|
2699
|
-
animation: dsbt-mark-enter 150ms ease-out;
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
/* The rail owns pointer input for the whole column, so a mark is a keyboard
|
|
2703
|
-
destination that paints one tick — never a mouse target of its own. */
|
|
2704
|
-
.dsbt-mark {
|
|
2705
|
-
position: absolute; inset: 0 0 0 auto; width: 20px; padding: 0; border: 0;
|
|
2706
|
-
border-radius: 8px; background: transparent; cursor: pointer;
|
|
2707
|
-
pointer-events: none;
|
|
2708
|
-
}
|
|
2709
|
-
.dsbt-mark::before {
|
|
2710
|
-
position: absolute; top: 50%; right: 0; width: 12px; height: 2px;
|
|
2711
|
-
border-radius: 2px; background: var(--dsw-alias-border-l4);
|
|
2712
|
-
content: ''; transform: translateY(-50%);
|
|
2713
|
-
transition: width 140ms ease, background-color 140ms ease;
|
|
2714
|
-
}
|
|
2715
|
-
.dsbt-markPreview::before { width: 18px; background: var(--dsw-alias-label-tertiary); }
|
|
2716
|
-
.dsbt-markActive::before { width: 20px; background: var(--dsw-alias-label-primary); }
|
|
2717
|
-
.dsbt-mark:focus-visible::before {
|
|
2718
|
-
width: 20px; background: var(--dsw-alias-state-business-primary);
|
|
2719
|
-
}
|
|
2720
|
-
.dsbt-mark:focus-visible {
|
|
2721
|
-
outline: 1px solid var(--dsw-alias-state-business-primary);
|
|
2722
|
-
outline-offset: 2px;
|
|
2723
|
-
}
|
|
2724
|
-
/* Entries without an anchor key (early events lacking a durable id) cannot
|
|
2725
|
-
jump; dim them instead of silently eating the click. */
|
|
2726
|
-
.dsbt-mark:disabled::before { width: 8px; opacity: .5; }
|
|
2727
|
-
|
|
2728
|
-
.dsbt-preview {
|
|
2729
|
-
--dsbt-preview-h: 100px;
|
|
2730
|
-
position: absolute;
|
|
2731
|
-
top: clamp(
|
|
2732
|
-
0px,
|
|
2733
|
-
calc(min(var(--dsbt-natural, 0px), var(--dsbt-ratio, 0%))
|
|
2734
|
-
+ var(--dsbt-inset, 6px) - var(--dsbt-preview-h) / 2),
|
|
2735
|
-
calc(100% - var(--dsbt-preview-h))
|
|
2736
|
-
);
|
|
2737
|
-
right: calc(100% + 10px); box-sizing: border-box;
|
|
2738
|
-
width: min(300px, calc(100vw - 120px)); max-height: var(--dsbt-preview-h);
|
|
2739
|
-
overflow: hidden; padding: 10px 12px;
|
|
2740
|
-
border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px;
|
|
2741
|
-
color: var(--dsw-alias-label-primary);
|
|
2742
|
-
background: var(--dsw-alias-bg-layer-1); box-shadow: var(--dsw-shadow-lv2);
|
|
2743
|
-
font: var(--dsw-font-xs-strong-13); pointer-events: none;
|
|
2744
|
-
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4;
|
|
2745
|
-
animation: dsbt-preview-enter 120ms ease-out;
|
|
2746
|
-
transition: top 140ms cubic-bezier(.2, .8, .2, 1);
|
|
2747
|
-
/* Content dissolve: a clamped preview's last lines soften out instead of
|
|
2748
|
-
hard-cutting. No effect on short copy (the mask's fade zone rides the
|
|
2749
|
-
empty tail) and no effect on ticks — it lives on the preview only. */
|
|
2750
|
-
mask-image: linear-gradient(180deg, #000 86%, transparent 100%);
|
|
2751
|
-
-webkit-mask-image: linear-gradient(180deg, #000 86%, transparent 100%);
|
|
2752
|
-
}
|
|
2753
|
-
|
|
2754
|
-
/* Hover preview is glass chrome just like the composer card: its official
|
|
2755
|
-
fill is the opaque bg-layer-1 token, so it needs the explicit-fill
|
|
2756
|
-
recipe (painter's composer token + shared blur/sheen chain). Gated on
|
|
2757
|
-
data-dsh-bg-glass like every other glassed surface. */
|
|
2758
|
-
body[data-dsh-bg-glass] .dsbt-preview {
|
|
2759
|
-
background-color: var(--dsw-specific-input-major);
|
|
2760
|
-
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));
|
|
2761
|
-
-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);
|
|
2762
|
-
backdrop-filter: blur(var(--bg-glass-blur, 16px)) saturate(var(--bg-glass-saturate, 1.42)) brightness(var(--bg-glass-brightness, 1)) contrast(1.01);
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
@keyframes dsbt-mark-enter { from { opacity: 0; } to { opacity: 1; } }
|
|
2766
|
-
@keyframes dsbt-preview-enter {
|
|
2767
|
-
from { opacity: 0; transform: translateX(4px); }
|
|
2768
|
-
to { opacity: 1; transform: translateX(0); }
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
@media (prefers-reduced-motion: reduce) {
|
|
2772
|
-
.dsbt-rail, .dsbt-markPosition, .dsbt-mark::before, .dsbt-preview {
|
|
2773
|
-
transition: none; animation: none;
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
`;
|
|
2777
|
-
//#endregion
|
|
2778
|
-
//#region src/client/timeline/legacy-rail.tsx
|
|
2779
|
-
/**
|
|
2780
|
-
* Legacy turn rail — the official `TurnNavigator` UI ported into this plugin
|
|
2781
|
-
* for kernels that do not publish an official turn-navigation index
|
|
2782
|
-
* (dsh <= 0.1.1).
|
|
2783
|
-
*
|
|
2784
|
-
* Structure, metrics and paints mirror `TurnNavigator.tsx` /
|
|
2785
|
-
* `TurnNavigator.module.css` from dsh-client-ui-chat; styles live in
|
|
2786
|
-
* ./legacy-rail-css.ts. The only differences are the ones a portaled box
|
|
2787
|
-
* forces:
|
|
2788
|
-
* - The official slot is a zero-height sticky box inside the chat scrollport;
|
|
2789
|
-
* this one is a fixed box centred on the same band (the transcript minus
|
|
2790
|
-
* the composer), positioned from the scrollport's live rect.
|
|
2791
|
-
* - The official rail reads the turn index the kernel assembles; this one
|
|
2792
|
-
* reads the shared backend (./source.ts), i.e. the host projection when it
|
|
2793
|
-
* exists and the loaded node window otherwise. That is why it can offer
|
|
2794
|
-
* turns the chat view has not loaded yet.
|
|
2795
|
-
*
|
|
2796
|
-
* Behaviour is identical to the enhanced official rail because both run the
|
|
2797
|
-
* same jump engine (./jump.ts): a click glides instead of teleporting, and
|
|
2798
|
-
* reaching for the rail pages older history in up to its uncompressed
|
|
2799
|
-
* capacity with the reader's view held still.
|
|
2800
|
-
*/
|
|
2801
|
-
if (typeof document !== "undefined") injectTimelineCss();
|
|
2802
|
-
/** Composer height assumed when neither the seat nor the kernel's own var is available. */
|
|
2803
|
-
const FALLBACK_COMPOSER_PX = 152;
|
|
2804
|
-
/** The official stylesheet's own ceiling and band deduction. */
|
|
2805
|
-
const RAIL_MAX_HEIGHT_PX = 420;
|
|
2806
|
-
const RAIL_BAND_DEDUCTION_PX = 64;
|
|
2807
|
-
/** How often the rail's host band and offset are re-measured. */
|
|
2808
|
-
const MEASURE_INTERVAL_MS = 400;
|
|
2809
|
-
/** Read one px-valued custom property off an element (undefined when absent). */
|
|
2810
|
-
function cssPx(el, name) {
|
|
2811
|
-
const raw = el.style.getPropertyValue(name);
|
|
2812
|
-
if (raw === "") return void 0;
|
|
2813
|
-
const px = Number.parseFloat(raw);
|
|
2814
|
-
return Number.isFinite(px) ? px : void 0;
|
|
2815
|
-
}
|
|
2816
|
-
/**
|
|
2817
|
-
* The band the rail may occupy: the transcript the reader actually sees, i.e.
|
|
2818
|
-
* the scrollport minus the sticky composer stack — the official measurement,
|
|
2819
|
-
* taken from the kernel's own vars when it publishes them (0.1.2+) and from
|
|
2820
|
-
* the composer seat otherwise.
|
|
2821
|
-
* @param sp - the conversation scrollport.
|
|
2822
|
-
*/
|
|
2823
|
-
function bandOf(sp) {
|
|
2824
|
-
const viewport = cssPx(sp, "--dsh-conversation-viewport-height") ?? sp.clientHeight;
|
|
2825
|
-
const seat = sp.querySelector("[data-composer-seat]");
|
|
2826
|
-
const composer = cssPx(sp, "--dsh-composer-height") ?? seat?.offsetHeight ?? FALLBACK_COMPOSER_PX;
|
|
2827
|
-
return Math.max(0, Math.min(RAIL_MAX_HEIGHT_PX, viewport - composer - RAIL_BAND_DEDUCTION_PX));
|
|
2828
|
-
}
|
|
2829
|
-
/** Tick metrics for one count, mirroring the official `railSize`. */
|
|
2830
|
-
function railSize(count) {
|
|
2831
|
-
return {
|
|
2832
|
-
["--dsbt-natural-h"]: `${(count - 1) * 10 + 12}px`,
|
|
2833
|
-
["--dsbt-inset"]: `6px`
|
|
2834
|
-
};
|
|
2835
|
-
}
|
|
2836
|
-
/** Position of one tick, mirroring the official `itemPosition`. */
|
|
2837
|
-
function tickPosition(index, count) {
|
|
2838
|
-
const ratio = count <= 1 ? 0 : index / (count - 1);
|
|
2839
|
-
return {
|
|
2840
|
-
["--dsbt-natural"]: `${index * 10}px`,
|
|
2841
|
-
["--dsbt-ratio"]: `${ratio * 100}%`
|
|
2842
|
-
};
|
|
2843
|
-
}
|
|
2844
|
-
/**
|
|
2845
|
-
* The legacy turn rail.
|
|
2846
|
-
* @param props - dock slot props plus the enabled gate.
|
|
2847
|
-
* @returns the portaled rail, or null when it must not render.
|
|
1825
|
+
* Rebuild the rail's full mark ladder exactly as the official ChatView does
|
|
1826
|
+
* (a mirror of `mergeTurnRailItems` in dsh-client-ui-chat): outline entries
|
|
1827
|
+
* seed every known turn as unloaded, loaded items then override with their
|
|
1828
|
+
* anchor key and richer previews, ascending by turn. The enhancer maps a
|
|
1829
|
+
* click to a mark by INDEX, so its ladder must match the RENDERED marks —
|
|
1830
|
+
* which are exactly this merge on an alpha.3 kernel; without the outline
|
|
1831
|
+
* value the merge degrades to the loaded items (the alpha.1/2 ladder).
|
|
1832
|
+
* @param loaded - the validated loaded-window items.
|
|
1833
|
+
* @param outline - the raw turnOutline wire value.
|
|
2848
1834
|
*/
|
|
2849
|
-
function
|
|
2850
|
-
const
|
|
2851
|
-
const
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
const railRef = react.default.useRef(null);
|
|
2864
|
-
react.default.useEffect(() => {
|
|
2865
|
-
aliveRef.current = true;
|
|
2866
|
-
return () => {
|
|
2867
|
-
aliveRef.current = false;
|
|
2868
|
-
};
|
|
2869
|
-
}, []);
|
|
2870
|
-
const entriesRef = react.default.useRef(entries);
|
|
2871
|
-
entriesRef.current = entries;
|
|
2872
|
-
react.default.useEffect(() => {
|
|
2873
|
-
if (entries.length === 0) return;
|
|
2874
|
-
const indexByKey = /* @__PURE__ */ new Map();
|
|
2875
|
-
entries.forEach((entry, index) => {
|
|
2876
|
-
if (entry.anchorKey !== void 0) indexByKey.set(entry.anchorKey, index);
|
|
1835
|
+
function mergeRailItems(loaded, outline) {
|
|
1836
|
+
const byTurn = /* @__PURE__ */ new Map();
|
|
1837
|
+
for (const entry of normalizeOutlineItems(outline)) byTurn.set(entry.turn, {
|
|
1838
|
+
turn: entry.turn,
|
|
1839
|
+
prompt: entry.prompt,
|
|
1840
|
+
response: entry.response
|
|
1841
|
+
});
|
|
1842
|
+
for (const item of loaded) {
|
|
1843
|
+
const preview = byTurn.get(item.turn);
|
|
1844
|
+
byTurn.set(item.turn, {
|
|
1845
|
+
turn: item.turn,
|
|
1846
|
+
prompt: item.prompt !== "" ? item.prompt : preview?.prompt ?? "",
|
|
1847
|
+
response: item.response !== "" ? item.response : preview?.response ?? "",
|
|
1848
|
+
anchorKey: item.anchorKey
|
|
2877
1849
|
});
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
let frame = 0;
|
|
2882
|
-
const measure = () => {
|
|
2883
|
-
frame = 0;
|
|
2884
|
-
const rect = sp.getBoundingClientRect();
|
|
2885
|
-
if (rect.height === 0) return;
|
|
2886
|
-
const line = rect.top + rect.height * .4;
|
|
2887
|
-
let best = -1;
|
|
2888
|
-
let bestDist = Infinity;
|
|
2889
|
-
for (const row of rows) {
|
|
2890
|
-
const key = row.getAttribute("data-chat-anchor-key");
|
|
2891
|
-
if (key === null) continue;
|
|
2892
|
-
const index = indexByKey.get(key);
|
|
2893
|
-
if (index === void 0) continue;
|
|
2894
|
-
const r = row.getBoundingClientRect();
|
|
2895
|
-
const dist = Math.abs(r.top + r.height / 2 - line);
|
|
2896
|
-
if (dist < bestDist) {
|
|
2897
|
-
bestDist = dist;
|
|
2898
|
-
best = index;
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
setActiveIndex(best);
|
|
2902
|
-
};
|
|
2903
|
-
const schedule = () => {
|
|
2904
|
-
if (frame === 0) frame = window.requestAnimationFrame(measure);
|
|
2905
|
-
};
|
|
2906
|
-
sp.addEventListener("scroll", schedule, { passive: true });
|
|
2907
|
-
const timer = window.setInterval(schedule, 2e3);
|
|
2908
|
-
schedule();
|
|
2909
|
-
return () => {
|
|
2910
|
-
sp.removeEventListener("scroll", schedule);
|
|
2911
|
-
window.clearInterval(timer);
|
|
2912
|
-
if (frame !== 0) cancelAnimationFrame(frame);
|
|
2913
|
-
};
|
|
2914
|
-
}, [entries]);
|
|
2915
|
-
react.default.useEffect(() => {
|
|
2916
|
-
let raf = 0;
|
|
2917
|
-
const measure = () => {
|
|
2918
|
-
raf = 0;
|
|
2919
|
-
const sp = document.querySelector("[data-conversation-scroll]");
|
|
2920
|
-
if (sp === null) return;
|
|
2921
|
-
const rect = sp.getBoundingClientRect();
|
|
2922
|
-
if (rect.width === 0 && rect.height === 0) return;
|
|
2923
|
-
setBand(bandOf(sp));
|
|
2924
|
-
const next = Math.max(8, Math.round(window.innerWidth - rect.right + 12));
|
|
2925
|
-
setRightOffset((prev) => Math.abs(prev - next) > .5 ? next : prev);
|
|
2926
|
-
};
|
|
2927
|
-
const schedule = () => {
|
|
2928
|
-
if (raf === 0) raf = window.requestAnimationFrame(measure);
|
|
2929
|
-
};
|
|
2930
|
-
const sp = document.querySelector("[data-conversation-scroll]");
|
|
2931
|
-
const ro = typeof ResizeObserver === "function" && sp !== null ? new ResizeObserver(schedule) : null;
|
|
2932
|
-
if (ro !== null && sp !== null) ro.observe(sp);
|
|
2933
|
-
window.addEventListener("resize", schedule);
|
|
2934
|
-
const timer = window.setInterval(schedule, MEASURE_INTERVAL_MS);
|
|
2935
|
-
measure();
|
|
2936
|
-
return () => {
|
|
2937
|
-
if (raf !== 0) cancelAnimationFrame(raf);
|
|
2938
|
-
ro?.disconnect();
|
|
2939
|
-
window.removeEventListener("resize", schedule);
|
|
2940
|
-
window.clearInterval(timer);
|
|
2941
|
-
};
|
|
2942
|
-
}, []);
|
|
2943
|
-
const navigate = react.default.useCallback((entry) => {
|
|
2944
|
-
if (entry.anchorKey === void 0) return;
|
|
2945
|
-
if (sessionsService === void 0 || sessionId === void 0) return;
|
|
2946
|
-
jumpToMessage(sessionsService, sessionId, entry.anchorKey, { followZonePx: 25 }).catch(() => {});
|
|
2947
|
-
}, [sessionsService, sessionId]);
|
|
2948
|
-
const warm = react.default.useCallback(async () => {
|
|
2949
|
-
if (session === void 0) return;
|
|
2950
|
-
if (warmingRef.current) return;
|
|
2951
|
-
const sp = document.querySelector("[data-conversation-scroll]");
|
|
2952
|
-
if (sp === null) return;
|
|
2953
|
-
warmingRef.current = true;
|
|
2954
|
-
try {
|
|
2955
|
-
await warmHistory(session, sp, {
|
|
2956
|
-
followZonePx: 25,
|
|
2957
|
-
alive: () => aliveRef.current,
|
|
2958
|
-
countOf: () => entriesRef.current.length,
|
|
2959
|
-
capacity: railCapacityOf(railRef.current)
|
|
2960
|
-
});
|
|
2961
|
-
} finally {
|
|
2962
|
-
warmingRef.current = false;
|
|
2963
|
-
}
|
|
2964
|
-
}, [session]);
|
|
2965
|
-
if (!enabled || sessionId === void 0 || sessionsService === void 0 || entries.length < 2) return null;
|
|
2966
|
-
const count = entries.length;
|
|
2967
|
-
const preview = previewIndex >= 0 && previewIndex < count ? entries[previewIndex] : void 0;
|
|
2968
|
-
return react_dom.default.createPortal(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2969
|
-
className: "dsbt-slot",
|
|
2970
|
-
style: { right: rightOffset },
|
|
2971
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
|
|
2972
|
-
className: "dsbt-rail",
|
|
2973
|
-
ref: railRef,
|
|
2974
|
-
style: {
|
|
2975
|
-
...railSize(count),
|
|
2976
|
-
["--dsbt-band"]: `${band}px`
|
|
2977
|
-
},
|
|
2978
|
-
"aria-label": t("timeline.railLabel"),
|
|
2979
|
-
onPointerMove: (event) => {
|
|
2980
|
-
setPreviewIndex(indexAtPointer(count, event.currentTarget, event.clientY));
|
|
2981
|
-
},
|
|
2982
|
-
onPointerLeave: () => {
|
|
2983
|
-
setPreviewIndex(-1);
|
|
2984
|
-
},
|
|
2985
|
-
onPointerEnter: () => {
|
|
2986
|
-
warm();
|
|
2987
|
-
},
|
|
2988
|
-
onFocus: () => {
|
|
2989
|
-
warm();
|
|
2990
|
-
},
|
|
2991
|
-
onClick: (event) => {
|
|
2992
|
-
const index = indexForEvent(event.currentTarget, event.nativeEvent);
|
|
2993
|
-
const entry = entries[index];
|
|
2994
|
-
if (entry !== void 0) navigate(entry);
|
|
2995
|
-
},
|
|
2996
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2997
|
-
className: "dsbt-marks",
|
|
2998
|
-
children: entries.map((entry, index) => {
|
|
2999
|
-
const active = index === activeIndex;
|
|
3000
|
-
const cls = active ? "dsbt-mark dsbt-markActive" : index === previewIndex ? "dsbt-mark dsbt-markPreview" : "dsbt-mark";
|
|
3001
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3002
|
-
className: "dsbt-markPosition",
|
|
3003
|
-
style: tickPosition(index, count),
|
|
3004
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3005
|
-
type: "button",
|
|
3006
|
-
className: cls,
|
|
3007
|
-
disabled: entry.anchorKey === void 0,
|
|
3008
|
-
"aria-label": t("timeline.jump", { turn: index + 1 }),
|
|
3009
|
-
"aria-current": active ? "true" : void 0,
|
|
3010
|
-
onFocus: () => {
|
|
3011
|
-
setPreviewIndex(index);
|
|
3012
|
-
},
|
|
3013
|
-
onBlur: () => {
|
|
3014
|
-
setPreviewIndex(-1);
|
|
3015
|
-
}
|
|
3016
|
-
})
|
|
3017
|
-
}, entry.seq);
|
|
3018
|
-
})
|
|
3019
|
-
}), preview !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3020
|
-
role: "tooltip",
|
|
3021
|
-
className: "dsbt-preview",
|
|
3022
|
-
style: tickPosition(previewIndex, count),
|
|
3023
|
-
children: preview.text === "" ? t("timeline.noText") : preview.text
|
|
3024
|
-
})]
|
|
3025
|
-
})
|
|
3026
|
-
}), document.body);
|
|
1850
|
+
}
|
|
1851
|
+
if (byTurn.size === 0) return [];
|
|
1852
|
+
return [...byTurn.values()].sort((left, right) => left.turn - right.turn);
|
|
3027
1853
|
}
|
|
3028
1854
|
//#endregion
|
|
3029
1855
|
//#region src/client/timeline/official-enhance.tsx
|
|
3030
1856
|
/**
|
|
3031
1857
|
* Official-rail enhancer — behaviour-only improvements to the kernel's own
|
|
3032
|
-
* turn navigator (dsh >= 0.1.2, `TurnNavigator` in dsh-client-ui-chat).
|
|
1858
|
+
* turn navigator (dsh >= 0.1.2-rc.1, `TurnNavigator` in dsh-client-ui-chat).
|
|
3033
1859
|
*
|
|
3034
1860
|
* This component renders NOTHING. It borrows the official rail exactly as the
|
|
3035
|
-
* kernel painted it and fixes its
|
|
3036
|
-
*
|
|
3037
|
-
* by version compare
|
|
1861
|
+
* kernel painted it and fixes its behaviour, scoped to what the frame-style
|
|
1862
|
+
* rail (dsh >= 0.1.2-alpha.3, unchanged on 0.1.2-rc.1) still lacks — told
|
|
1863
|
+
* apart by capability, never by version compare:
|
|
3038
1864
|
*
|
|
3039
|
-
*
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
*
|
|
3045
|
-
*
|
|
3046
|
-
*
|
|
3047
|
-
*
|
|
3048
|
-
*
|
|
3049
|
-
*
|
|
3050
|
-
*
|
|
3051
|
-
* 2. Reachable history (compressing rail only — dsh 0.1.2-alpha.1/2). That
|
|
3052
|
-
* rail only lists turns the client has already loaded, so anything behind
|
|
3053
|
-
* the "load earlier" button has no mark at all. Hovering or focusing the
|
|
3054
|
-
* rail pages older history in — only up to the rail's own uncompressed
|
|
3055
|
-
* capacity, so the marks never degrade into a solid bar — with the scroll
|
|
3056
|
-
* position compensated so the reader's view never moves. The alpha.3
|
|
3057
|
-
* frame-style rail lists every turn already and pages on demand itself, so
|
|
3058
|
-
* warming there would fight the kernel's pager for nothing and does not
|
|
3059
|
-
* run.
|
|
1865
|
+
* Smooth jump. The official `navigateToTurn` is one assignment
|
|
1866
|
+
* (`el.scrollTop += flowTop(row, el) - 24`); a click across a long transcript
|
|
1867
|
+
* teleports. Clicks on LOADED marks are intercepted in the capture phase and
|
|
1868
|
+
* re-run through the shared glide instead. The frame-style rail also renders
|
|
1869
|
+
* marks for turns OUTSIDE the loaded window (fed by the whole-log
|
|
1870
|
+
* `turnOutline` projection); those jumps page history through the kernel's
|
|
1871
|
+
* own loadThrough machinery — marks without an anchor key are left to it, and
|
|
1872
|
+
* the click index is resolved against the same merged ladder the kernel
|
|
1873
|
+
* renders. While one of those kernel jumps is still paging (a mark pulses
|
|
1874
|
+
* `aria-busy`), the plugin stands down entirely: the kernel's loaded branch
|
|
1875
|
+
* cancels its own pending jump before landing, and an interception would
|
|
1876
|
+
* bypass that cancellation.
|
|
3060
1877
|
*
|
|
3061
1878
|
* State-ownership note (why intercepting the official click loses nothing):
|
|
3062
1879
|
* the kernel's scrollport (`data-conversation-scroll`, ChatView's
|
|
@@ -3069,8 +1886,7 @@ window.__ModuleLoader__.load({
|
|
|
3069
1886
|
* handler's synchronous `setActiveTurn(item.turn)`, which its own
|
|
3070
1887
|
* scroll-driven active derivation re-settles by the time the glide lands.
|
|
3071
1888
|
*
|
|
3072
|
-
*
|
|
3073
|
-
* behaves identically.
|
|
1889
|
+
* The jump goes through the shared backend (jump.ts).
|
|
3074
1890
|
*
|
|
3075
1891
|
* Interception notes: React 18 dispatches `onClick` from a listener on the
|
|
3076
1892
|
* root container during the BUBBLE phase. A capture listener on the rail
|
|
@@ -3083,204 +1899,393 @@ window.__ModuleLoader__.load({
|
|
|
3083
1899
|
* React-owned subtree and no official class or paint is overridden.
|
|
3084
1900
|
*/
|
|
3085
1901
|
/**
|
|
3086
|
-
* Structural anchor of the official rail — its inline style carries the
|
|
3087
|
-
* own
|
|
3088
|
-
*
|
|
3089
|
-
* isFrameRail
|
|
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.
|
|
2082
|
+
*/
|
|
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
|
+
}
|
|
2087
|
+
/**
|
|
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.
|
|
3090
2096
|
*/
|
|
3091
|
-
|
|
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
|
+
}
|
|
3092
2105
|
/**
|
|
3093
|
-
*
|
|
3094
|
-
*
|
|
3095
|
-
*
|
|
3096
|
-
*
|
|
3097
|
-
*
|
|
3098
|
-
*
|
|
3099
|
-
* (this instance must stand down). Weak: a replaced rail element disappears
|
|
3100
|
-
* as soon as the browser collects it, so remounts need no bookkeeping.
|
|
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).
|
|
3101
2112
|
*/
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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
|
+
}
|
|
3105
2119
|
/**
|
|
3106
|
-
*
|
|
3107
|
-
*
|
|
3108
|
-
*
|
|
3109
|
-
*
|
|
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.
|
|
3110
2127
|
*/
|
|
3111
|
-
function
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
const
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
ladderRef.current = ladder;
|
|
3126
|
-
const railRef = react.default.useRef(rail);
|
|
3127
|
-
railRef.current = rail;
|
|
3128
|
-
const aliveRef = react.default.useRef(true);
|
|
3129
|
-
const enabledRef = react.default.useRef(enabled);
|
|
3130
|
-
enabledRef.current = enabled;
|
|
3131
|
-
const warmingRef = react.default.useRef(false);
|
|
3132
|
-
react.default.useEffect(() => {
|
|
3133
|
-
aliveRef.current = true;
|
|
3134
|
-
return () => {
|
|
3135
|
-
aliveRef.current = false;
|
|
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);
|
|
3136
2142
|
};
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
return;
|
|
3150
|
-
}
|
|
3151
|
-
claimedRails.set(found, claimToken);
|
|
3152
|
-
setRail(found);
|
|
3153
|
-
setNarrow(isFrameRail(found));
|
|
3154
|
-
reportTimelineMode(isFrameRail(found) ? "narrow" : "enhance");
|
|
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);
|
|
3155
2155
|
};
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
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);
|
|
3161
2172
|
};
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
const
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
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
|
+
}
|
|
3188
2217
|
try {
|
|
3189
|
-
await
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
capacity: railCapacityOf(rail)
|
|
3194
|
-
});
|
|
3195
|
-
} finally {
|
|
3196
|
-
warmingRef.current = false;
|
|
2218
|
+
await session.loadOlder();
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
console.warn("[deepseek-harness-background] timeline jump paging failed:", error);
|
|
2221
|
+
break;
|
|
3197
2222
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
rail.removeEventListener("focusin", start);
|
|
3229
|
-
};
|
|
3230
|
-
}, [
|
|
3231
|
-
enabled,
|
|
3232
|
-
narrow,
|
|
3233
|
-
rail,
|
|
3234
|
-
warm
|
|
3235
|
-
]);
|
|
3236
|
-
return null;
|
|
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)));
|
|
3237
2253
|
}
|
|
3238
2254
|
//#endregion
|
|
3239
2255
|
//#region src/client/timeline/index.tsx
|
|
3240
2256
|
/**
|
|
3241
|
-
* Dock-slot entry for the conversation timeline:
|
|
3242
|
-
*
|
|
2257
|
+
* Dock-slot entry for the conversation timeline: mounts the official-rail
|
|
2258
|
+
* enhancer on kernels that publish the turn-navigation index.
|
|
3243
2259
|
*
|
|
3244
|
-
*
|
|
3245
|
-
*
|
|
3246
|
-
* as a session hook (`useChat(s => s.navigation.items())
|
|
3247
|
-
* the presence of that hook IS the presence of the official rail.
|
|
3248
|
-
*
|
|
3249
|
-
*
|
|
2260
|
+
* Supported dsh baseline: >= 0.1.2-rc.1. Every such kernel ships the official
|
|
2261
|
+
* turn rail (`TurnNavigator` in dsh-client-ui-chat), rendered from the very
|
|
2262
|
+
* index `ui-chat` publishes as a session hook (`useChat(s => s.navigation.items())`),
|
|
2263
|
+
* so the presence of that hook IS the presence of the official rail. This
|
|
2264
|
+
* plugin therefore never renders a rail of its own — it only improves the
|
|
2265
|
+
* official one's behaviour (smooth glide for loaded marks, standing down
|
|
2266
|
+
* while the kernel's own loadThrough jump is still paging).
|
|
3250
2267
|
*
|
|
3251
|
-
* -
|
|
3252
|
-
*
|
|
3253
|
-
*
|
|
3254
|
-
*
|
|
3255
|
-
*
|
|
3256
|
-
* The detected mode is published to ./mode-store.ts because the settings row
|
|
3257
|
-
* (root-scoped, no session) cannot probe the kernel itself but must switch its
|
|
3258
|
-
* copy between "conversation timeline" and "conversation timeline enhancement".
|
|
2268
|
+
* The hook-missing branch is a defensive no-op, not a second frontend: on
|
|
2269
|
+
* kernels without the official rail (everything before 0.1.2) this plugin
|
|
2270
|
+
* renders nothing, exactly as the supported-version contract requires
|
|
2271
|
+
* (README directs those users to an older plugin release).
|
|
3259
2272
|
*/
|
|
3260
2273
|
/**
|
|
3261
2274
|
* Mount the timeline frontend the running kernel supports.
|
|
3262
2275
|
* @param props - dock slot props.
|
|
3263
|
-
* @returns the enhancer or the
|
|
2276
|
+
* @returns the enhancer, or nothing on kernels without the official rail.
|
|
3264
2277
|
*/
|
|
3265
2278
|
function TimelineBridge(props) {
|
|
3266
2279
|
const { useChat, t, ...rest } = props;
|
|
3267
2280
|
const settings = react.default.useSyncExternalStore(settingsClient.subscribe, settingsClient.getSnapshot);
|
|
3268
2281
|
const enabled = settings.value?.timeline !== false && settings.status !== "loading";
|
|
3269
|
-
|
|
3270
|
-
react.default.
|
|
3271
|
-
reportTimelineMode(mode);
|
|
3272
|
-
}, [mode]);
|
|
3273
|
-
if (mode === "enhance") return react.default.createElement(OfficialTimelineEnhancer, {
|
|
2282
|
+
if (useChat === void 0) return null;
|
|
2283
|
+
return react.default.createElement(OfficialTimelineEnhancer, {
|
|
3274
2284
|
...rest,
|
|
3275
2285
|
useChat,
|
|
3276
2286
|
enabled,
|
|
3277
2287
|
t
|
|
3278
2288
|
});
|
|
3279
|
-
return react.default.createElement(LegacyTimelineRail, {
|
|
3280
|
-
...rest,
|
|
3281
|
-
enabled,
|
|
3282
|
-
t
|
|
3283
|
-
});
|
|
3284
2289
|
}
|
|
3285
2290
|
/** localStorage prefix the removed key-point bookmark feature used (< 0.5). */
|
|
3286
2291
|
const MARKS_STORAGE_PREFIX = "dsbt_marks_";
|
|
@@ -3350,7 +2355,14 @@ window.__ModuleLoader__.load({
|
|
|
3350
2355
|
id: "deepseek-harness-background.timeline",
|
|
3351
2356
|
order: 45,
|
|
3352
2357
|
locale: "ui-background",
|
|
3353
|
-
inject: () => ({
|
|
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
|
+
} })
|
|
3354
2366
|
}, TimelineBridge));
|
|
3355
2367
|
ctx.effect(() => () => {
|
|
3356
2368
|
unsubscribe();
|