@luziyang2026/dsh-question-nav 0.1.0 → 0.2.0
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 +23 -16
- package/README.zh.md +20 -14
- package/lib/client.js +215 -24
- package/lib/client.js.map +1 -1
- package/lib/types/client/QuestionNavStrip.d.ts +3 -0
- package/lib/types/client/index.d.ts +5 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/load-all.d.ts +53 -0
- package/package.json +1 -1
- package/src/client/QuestionNavStrip.tsx +78 -4
- package/src/client/index.ts +53 -3
- package/src/client/locales.ts +6 -0
- package/src/client/question-nav.module.css +24 -0
- package/src/core/load-all.ts +97 -0
package/README.md
CHANGED
|
@@ -10,11 +10,18 @@ It is an external plugin bundle with zero runtime dependencies — the browser
|
|
|
10
10
|
half (`lib/client.js`) externalizes everything to the DSH shell, so it adds
|
|
11
11
|
only a small bundle to the GUI at load time.
|
|
12
12
|
|
|
13
|
+
Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm]).
|
|
14
|
+
|
|
13
15
|
## What it does
|
|
14
16
|
|
|
15
17
|
- **Left-edge dot minimap** (embedded, not reserving any width).
|
|
16
18
|
- **Vertically centered** in the conversation column.
|
|
17
19
|
- **One dot = one user question**, with a small count above the dot column.
|
|
20
|
+
- **Full history**: on show, the strip auto-expands the session's older pages
|
|
21
|
+
(paging DSH's own "load older" window) so even questions that are still
|
|
22
|
+
collapsed behind the load-more button surface as dots. While expanding, the
|
|
23
|
+
count shows a "…" affordance; if the safety budget is exhausted a dimmed
|
|
24
|
+
dashed dot above the oldest question offers "load earlier" on click.
|
|
18
25
|
- **Hover**: the dot enlarges and an instant tooltip (portal-rendered, no
|
|
19
26
|
native-title delay) shows the question's **full text**.
|
|
20
27
|
- **Click**: jumps to that question, paging older history when the target is
|
|
@@ -22,35 +29,34 @@ only a small bundle to the GUI at load time.
|
|
|
22
29
|
- Empty/left areas of the rail pass pointer events through to the conversation
|
|
23
30
|
(it never blocks the chat).
|
|
24
31
|
|
|
25
|
-
##
|
|
32
|
+
## Requirements
|
|
26
33
|
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
- `user` and `steering` user messages count as questions.
|
|
34
|
+
- DeepSeek Harness Web GUI (a runnable DSH profile; the `dsh` CLI).
|
|
35
|
+
- Node.js `>= 20` (matches the DSH SDK peer range).
|
|
30
36
|
|
|
31
|
-
## Install
|
|
37
|
+
## Install
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
profile as an installable [bundle][bundle].
|
|
39
|
+
Install the prebuilt package from npm and add it to a profile:
|
|
35
40
|
|
|
36
41
|
```sh
|
|
37
|
-
|
|
38
|
-
pnpm build # tsc declarations + tsdown client bundle -> lib/
|
|
42
|
+
dsh plugin --profile web add @luziyang2026/dsh-question-nav
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
Then restart the DSH Web GUI to load the new bundle.
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
dsh plugin --profile web add ./dsh-question-nav
|
|
45
|
-
```
|
|
47
|
+
### Build from source / develop locally
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
This repository is a standalone plugin project — build it, then add the
|
|
50
|
+
checkout to a profile as an installable [bundle][bundle]:
|
|
48
51
|
|
|
49
52
|
```sh
|
|
50
|
-
|
|
53
|
+
pnpm install # install devDependencies (DSH SDK peers, tsdown, vitest)
|
|
54
|
+
pnpm build # tsc declarations + tsdown client bundle -> lib/
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
```sh
|
|
58
|
+
dsh plugin --profile web add ./dsh-question-nav
|
|
59
|
+
```
|
|
54
60
|
|
|
55
61
|
## Development
|
|
56
62
|
|
|
@@ -73,4 +79,5 @@ module table.
|
|
|
73
79
|
BSD-3-Clause.
|
|
74
80
|
|
|
75
81
|
[dsh]: https://github.com/deepseek-harness/deepseek-harness
|
|
82
|
+
[npm]: https://www.npmjs.com/package/@luziyang2026/dsh-question-nav
|
|
76
83
|
[bundle]: https://github.com/deepseek-harness/deepseek-harness
|
package/README.zh.md
CHANGED
|
@@ -7,42 +7,47 @@
|
|
|
7
7
|
它是外部插件 bundle,**零运行时依赖** —— 浏览器半区(`lib/client.js`)把一切
|
|
8
8
|
外部化给 DSH 外壳,加载时只往 GUI 里加一个很小的 bundle。
|
|
9
9
|
|
|
10
|
+
包名:**`@luziyang2026/dsh-question-nav`**([npm][npm])。
|
|
11
|
+
|
|
10
12
|
## 功能
|
|
11
13
|
|
|
12
14
|
- **左缘圆点迷你地图**:内嵌,**不占任何宽度**。
|
|
13
15
|
- **垂直居中**在对话栏中。
|
|
14
16
|
- **一个圆点 = 一个用户提问**,圆点列上方有提问数量。
|
|
17
|
+
- **完整历史**:显示时自动向后翻页展开整个会话历史,连"加载更早"按钮后面
|
|
18
|
+
折叠隐藏的老提问也会变成圆点。展开过程中数量后显示 "…";若超过安全预算,
|
|
19
|
+
最老提问上方会出现一个虚线圆点,点击可继续加载更早提问。
|
|
15
20
|
- **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示**全文**。
|
|
16
21
|
- **点击**:跳转到该提问,目标尚未加载时自动 `loadOlder` 扩窗(并落到最近可见行兜底)。
|
|
17
22
|
- 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
|
|
18
23
|
|
|
19
|
-
##
|
|
24
|
+
## 环境要求
|
|
20
25
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
26
|
+
- DeepSeek Harness Web GUI(可运行的 DSH profile;需要 `dsh` CLI)。
|
|
27
|
+
- Node.js `>= 20`(与 DSH SDK peer 范围一致)。
|
|
23
28
|
|
|
24
|
-
##
|
|
29
|
+
## 安装
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
从 npm 装预构建包并加进 profile:
|
|
27
32
|
|
|
28
33
|
```sh
|
|
29
|
-
|
|
30
|
-
pnpm build # tsc 声明 + tsdown client bundle -> lib/
|
|
34
|
+
dsh plugin --profile web add @luziyang2026/dsh-question-nav
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
然后重启 DSH Web GUI 以加载新 bundle。
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
dsh plugin --profile web add ./dsh-question-nav
|
|
37
|
-
```
|
|
39
|
+
### 从源码构建 / 本地开发
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
本仓库是独立插件工程 —— 先构建,再作为可安装的 [bundle][bundle] 加进 profile:
|
|
40
42
|
|
|
41
43
|
```sh
|
|
42
|
-
|
|
44
|
+
pnpm install # 安装 devDependencies(DSH SDK peers、tsdown、vitest)
|
|
45
|
+
pnpm build # tsc 声明 + tsdown client bundle -> lib/
|
|
43
46
|
```
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
```sh
|
|
49
|
+
dsh plugin --profile web add ./dsh-question-nav
|
|
50
|
+
```
|
|
46
51
|
|
|
47
52
|
## 开发
|
|
48
53
|
|
|
@@ -64,4 +69,5 @@ externals 经加载器模块表解析。
|
|
|
64
69
|
BSD-3-Clause。
|
|
65
70
|
|
|
66
71
|
[dsh]: https://github.com/deepseek-harness/deepseek-harness
|
|
72
|
+
[npm]: https://www.npmjs.com/package/@luziyang2026/dsh-question-nav
|
|
67
73
|
[bundle]: https://github.com/deepseek-harness/deepseek-harness
|
package/lib/client.js
CHANGED
|
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
let react_dom = require("react-dom");
|
|
9
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
10
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
11
|
-
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}";
|
|
11
|
+
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_moreDot{border:1px dashed var(--dsw-alias-border-l3);background:0 0}.TWf_pa_moreDot:hover{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_countLoading{color:var(--dsw-alias-brand-primary);margin-left:2px;font-weight:400;animation:1.2s ease-in-out infinite TWf_pa_qnPulse}@keyframes TWf_pa_qnPulse{0%,to{opacity:.4}50%{opacity:1}}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}";
|
|
12
12
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
13
13
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
14
14
|
const tag = document.createElement("style");
|
|
@@ -20,10 +20,13 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
var question_nav_module_css_default = {
|
|
21
21
|
"active": "TWf_pa_active",
|
|
22
22
|
"count": "TWf_pa_count",
|
|
23
|
+
"countLoading": "TWf_pa_countLoading",
|
|
23
24
|
"dot": "TWf_pa_dot",
|
|
24
25
|
"dots": "TWf_pa_dots",
|
|
25
26
|
"empty": "TWf_pa_empty",
|
|
26
27
|
"list": "TWf_pa_list",
|
|
28
|
+
"moreDot": "TWf_pa_moreDot",
|
|
29
|
+
"qnPulse": "TWf_pa_qnPulse",
|
|
27
30
|
"rail": "TWf_pa_rail",
|
|
28
31
|
"tooltip": "TWf_pa_tooltip"
|
|
29
32
|
};
|
|
@@ -37,9 +40,15 @@ window.__ModuleLoader__.load({
|
|
|
37
40
|
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
38
41
|
* the chat to that question.
|
|
39
42
|
*
|
|
43
|
+
* Dots index the WHOLE session history, not just the currently loaded window:
|
|
44
|
+
* on show, the strip auto-expands older pages (`loadAllOlder`) so questions
|
|
45
|
+
* that still sit behind DSH's "load older" button are surfaced too. While the
|
|
46
|
+
* expansion is running the count shows a "…" affordance; if the safety budget
|
|
47
|
+
* is exhausted a dimmed "load earlier" dot appears above the oldest question.
|
|
48
|
+
*
|
|
40
49
|
* Data arrives through the four props shares: the framework `useSessions`
|
|
41
|
-
* hook (current session), the registrant inject face (read/subscribe/jump
|
|
42
|
-
* and the bound locale translator.
|
|
50
|
+
* hook (current session), the registrant inject face (read/subscribe/jump/
|
|
51
|
+
* load-all), and the bound locale translator.
|
|
43
52
|
*/
|
|
44
53
|
const FAILURE_HINTS = {
|
|
45
54
|
VIEW_INACTIVE: "jump.inactive",
|
|
@@ -58,8 +67,14 @@ window.__ModuleLoader__.load({
|
|
|
58
67
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
59
68
|
const [hint, setHint] = (0, react.useState)(null);
|
|
60
69
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
70
|
+
const [loadingAll, setLoadingAll] = (0, react.useState)(false);
|
|
71
|
+
const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
|
|
61
72
|
const panelRef = (0, react.useRef)(null);
|
|
62
73
|
const hintTimerRef = (0, react.useRef)(null);
|
|
74
|
+
/** Abort controller for the in-flight expansion (cancelled on session change). */
|
|
75
|
+
const loadAllAbortRef = (0, react.useRef)(null);
|
|
76
|
+
/** Session whose expansion is already running, to avoid duplicate loops. */
|
|
77
|
+
const loadingAllSessionRef = (0, react.useRef)(null);
|
|
63
78
|
const showHint = (message) => {
|
|
64
79
|
setHint(message);
|
|
65
80
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -83,6 +98,36 @@ window.__ModuleLoader__.load({
|
|
|
83
98
|
current,
|
|
84
99
|
props
|
|
85
100
|
]);
|
|
101
|
+
(0, react.useEffect)(() => {
|
|
102
|
+
if (!visible || current === void 0) {
|
|
103
|
+
loadAllAbortRef.current?.abort();
|
|
104
|
+
loadAllAbortRef.current = null;
|
|
105
|
+
loadingAllSessionRef.current = null;
|
|
106
|
+
setLoadingAll(false);
|
|
107
|
+
setMoreAvailable(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (loadingAllSessionRef.current === current) return;
|
|
111
|
+
loadingAllSessionRef.current = current;
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
loadAllAbortRef.current = controller;
|
|
114
|
+
setLoadingAll(true);
|
|
115
|
+
setMoreAvailable(false);
|
|
116
|
+
props.loadAllOlder(current, { signal: controller.signal }).then((result) => {
|
|
117
|
+
setMoreAvailable(result.code === "BUDGET" && !result.ok);
|
|
118
|
+
}).finally(() => {
|
|
119
|
+
setLoadingAll(false);
|
|
120
|
+
if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null;
|
|
121
|
+
loadingAllSessionRef.current = null;
|
|
122
|
+
});
|
|
123
|
+
return () => {
|
|
124
|
+
controller.abort();
|
|
125
|
+
};
|
|
126
|
+
}, [
|
|
127
|
+
visible,
|
|
128
|
+
current,
|
|
129
|
+
props
|
|
130
|
+
]);
|
|
86
131
|
(0, react.useEffect)(() => {
|
|
87
132
|
const onJumpFailed = (event) => {
|
|
88
133
|
const code = event.detail;
|
|
@@ -137,6 +182,14 @@ window.__ModuleLoader__.load({
|
|
|
137
182
|
props.jump(current, node.key);
|
|
138
183
|
window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
|
|
139
184
|
};
|
|
185
|
+
const onLoadMore = () => {
|
|
186
|
+
if (current === void 0) return;
|
|
187
|
+
setMoreAvailable(false);
|
|
188
|
+
setLoadingAll(true);
|
|
189
|
+
props.loadAllOlder(current).then((result) => {
|
|
190
|
+
setMoreAvailable(result.code === "BUDGET" && !result.ok);
|
|
191
|
+
}).finally(() => setLoadingAll(false));
|
|
192
|
+
};
|
|
140
193
|
const t = props.t;
|
|
141
194
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
142
195
|
ref: panelRef,
|
|
@@ -152,26 +205,47 @@ window.__ModuleLoader__.load({
|
|
|
152
205
|
className: question_nav_module_css_default.list,
|
|
153
206
|
children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
154
207
|
className: question_nav_module_css_default.empty,
|
|
155
|
-
children: t("strip.empty")
|
|
208
|
+
children: loadingAll ? t("strip.loadingAll") : t("strip.empty")
|
|
156
209
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
157
210
|
className: question_nav_module_css_default.dots,
|
|
158
|
-
children: [
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
211
|
+
children: [
|
|
212
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
213
|
+
className: question_nav_module_css_default.count,
|
|
214
|
+
children: [questions.length, loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
215
|
+
className: question_nav_module_css_default.countLoading,
|
|
216
|
+
children: t("strip.loadingSuffix")
|
|
217
|
+
}) : null]
|
|
218
|
+
}),
|
|
219
|
+
moreAvailable && !loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
220
|
+
className: `${question_nav_module_css_default.dot} ${question_nav_module_css_default.moreDot}`,
|
|
221
|
+
"aria-label": t("strip.loadEarlier"),
|
|
222
|
+
title: t("strip.loadEarlier"),
|
|
223
|
+
onMouseEnter: (e) => {
|
|
224
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
225
|
+
setTooltip({
|
|
226
|
+
text: t("strip.loadEarlier"),
|
|
227
|
+
left: r.right + 10,
|
|
228
|
+
top: r.top
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
onMouseLeave: () => setTooltip(null),
|
|
232
|
+
onClick: onLoadMore
|
|
233
|
+
}) : null,
|
|
234
|
+
questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
235
|
+
className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
236
|
+
"aria-label": node.text,
|
|
237
|
+
onMouseEnter: (e) => {
|
|
238
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
239
|
+
setTooltip({
|
|
240
|
+
text: node.text,
|
|
241
|
+
left: r.right + 10,
|
|
242
|
+
top: r.top
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
onMouseLeave: () => setTooltip(null),
|
|
246
|
+
onClick: () => onJump(node)
|
|
247
|
+
}, node.key))
|
|
248
|
+
]
|
|
175
249
|
})
|
|
176
250
|
}),
|
|
177
251
|
tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -193,6 +267,9 @@ window.__ModuleLoader__.load({
|
|
|
193
267
|
*/
|
|
194
268
|
const zh = {
|
|
195
269
|
"strip.empty": "本会话还没有提问",
|
|
270
|
+
"strip.loadingAll": "正在加载全部历史…",
|
|
271
|
+
"strip.loadingSuffix": "…",
|
|
272
|
+
"strip.loadEarlier": "加载更早的问题",
|
|
196
273
|
"jump.inactive": "聊天视图未激活",
|
|
197
274
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
198
275
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
@@ -200,6 +277,9 @@ window.__ModuleLoader__.load({
|
|
|
200
277
|
};
|
|
201
278
|
const en = {
|
|
202
279
|
"strip.empty": "No questions in this session yet",
|
|
280
|
+
"strip.loadingAll": "Loading full history…",
|
|
281
|
+
"strip.loadingSuffix": "…",
|
|
282
|
+
"strip.loadEarlier": "Load earlier questions",
|
|
203
283
|
"jump.inactive": "Chat view is not active",
|
|
204
284
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
205
285
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
@@ -258,7 +338,7 @@ window.__ModuleLoader__.load({
|
|
|
258
338
|
* paging/timeout/fallback loop is unit-testable without a real browser or
|
|
259
339
|
* session. The browser half wires these ports to ctx.sessions + the DOM.
|
|
260
340
|
*/
|
|
261
|
-
const DEFAULTS = {
|
|
341
|
+
const DEFAULTS$1 = {
|
|
262
342
|
totalTimeoutMs: 15e3,
|
|
263
343
|
maxPages: 100,
|
|
264
344
|
rowWaitMs: 8e3,
|
|
@@ -287,7 +367,7 @@ window.__ModuleLoader__.load({
|
|
|
287
367
|
*/
|
|
288
368
|
async function jumpToQuestion(ports, key, options = {}) {
|
|
289
369
|
const cfg = {
|
|
290
|
-
...DEFAULTS,
|
|
370
|
+
...DEFAULTS$1,
|
|
291
371
|
...options
|
|
292
372
|
};
|
|
293
373
|
const fail = (code, fallback = false) => {
|
|
@@ -353,6 +433,72 @@ window.__ModuleLoader__.load({
|
|
|
353
433
|
return fail("TARGET_HIDDEN", false);
|
|
354
434
|
}
|
|
355
435
|
//#endregion
|
|
436
|
+
//#region src/core/load-all.ts
|
|
437
|
+
const DEFAULTS = {
|
|
438
|
+
maxPages: 400,
|
|
439
|
+
totalTimeoutMs: 6e4,
|
|
440
|
+
pollMs: 60
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Expand the session window backwards until the earliest history is loaded.
|
|
444
|
+
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
445
|
+
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
446
|
+
* immediately with `COMPLETE`.
|
|
447
|
+
*/
|
|
448
|
+
async function loadAllOlder(ports, options = {}) {
|
|
449
|
+
const cfg = {
|
|
450
|
+
...DEFAULTS,
|
|
451
|
+
...options
|
|
452
|
+
};
|
|
453
|
+
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
454
|
+
let pages = 0;
|
|
455
|
+
const cancelled = () => cfg.signal?.aborted === true;
|
|
456
|
+
while (true) {
|
|
457
|
+
if (cancelled()) return {
|
|
458
|
+
ok: false,
|
|
459
|
+
code: "CANCELLED",
|
|
460
|
+
pages
|
|
461
|
+
};
|
|
462
|
+
if (!ports.isViewActive()) return {
|
|
463
|
+
ok: false,
|
|
464
|
+
code: "VIEW_INACTIVE",
|
|
465
|
+
pages
|
|
466
|
+
};
|
|
467
|
+
const snap = ports.snapshot();
|
|
468
|
+
if (snap === void 0) return {
|
|
469
|
+
ok: false,
|
|
470
|
+
code: "VIEW_INACTIVE",
|
|
471
|
+
pages
|
|
472
|
+
};
|
|
473
|
+
if (snap.openState === "error") return {
|
|
474
|
+
ok: false,
|
|
475
|
+
code: "NOT_OPEN",
|
|
476
|
+
pages
|
|
477
|
+
};
|
|
478
|
+
if (snap.hasMore !== true) return {
|
|
479
|
+
ok: true,
|
|
480
|
+
code: "COMPLETE",
|
|
481
|
+
pages
|
|
482
|
+
};
|
|
483
|
+
if (pages >= cfg.maxPages) return {
|
|
484
|
+
ok: false,
|
|
485
|
+
code: "BUDGET",
|
|
486
|
+
pages
|
|
487
|
+
};
|
|
488
|
+
if (ports.now() > deadline) return {
|
|
489
|
+
ok: false,
|
|
490
|
+
code: "TIMEOUT",
|
|
491
|
+
pages
|
|
492
|
+
};
|
|
493
|
+
if (snap.openState !== "open" || snap.loadingOlder) {
|
|
494
|
+
await ports.sleep(cfg.pollMs);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
await ports.loadOlder();
|
|
498
|
+
pages += 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
//#endregion
|
|
356
502
|
//#region src/client/index.ts
|
|
357
503
|
/** Locale namespace this plugin owns. */
|
|
358
504
|
const NS = "question-nav";
|
|
@@ -398,6 +544,50 @@ window.__ModuleLoader__.load({
|
|
|
398
544
|
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
399
545
|
};
|
|
400
546
|
}
|
|
547
|
+
/** Resolve the active conversation scrollport (or null when not mounted). */
|
|
548
|
+
function scrollport() {
|
|
549
|
+
return document.querySelector("[data-conversation-scroll]");
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* One backward page that preserves the reader's scroll position. DSH's own
|
|
553
|
+
* "load older" button arms a paging anchor; a programmatic `loadOlder()` does
|
|
554
|
+
* not, so without this compensation prepended content would push the visible
|
|
555
|
+
* rows down. We restore by the exact growth of the scrollHeight.
|
|
556
|
+
*/
|
|
557
|
+
async function pagedLoadOlder(ctx, sessionId) {
|
|
558
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
559
|
+
if (binding === void 0) return;
|
|
560
|
+
const port = scrollport();
|
|
561
|
+
const beforeHeight = port?.scrollHeight ?? 0;
|
|
562
|
+
const beforeTop = port?.scrollTop ?? 0;
|
|
563
|
+
await binding.session.loadOlder();
|
|
564
|
+
if (port === null) return;
|
|
565
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
566
|
+
const delta = port.scrollHeight - beforeHeight;
|
|
567
|
+
if (delta > 0) port.scrollTop = beforeTop + delta;
|
|
568
|
+
}
|
|
569
|
+
/** Map the session to the load-all port surface. */
|
|
570
|
+
function loadAllPortsFor(ctx, sessionId) {
|
|
571
|
+
return {
|
|
572
|
+
snapshot: () => {
|
|
573
|
+
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
574
|
+
if (snap === void 0) return void 0;
|
|
575
|
+
return {
|
|
576
|
+
openState: snap.openState,
|
|
577
|
+
hasMore: snap.hasMore,
|
|
578
|
+
loadingOlder: snap.loadingOlder
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
loadOlder: () => pagedLoadOlder(ctx, sessionId),
|
|
582
|
+
isViewActive: () => document.querySelector("[data-chat-flow]") !== null,
|
|
583
|
+
now: () => Date.now(),
|
|
584
|
+
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** Expand the whole session history so every question becomes a dot. */
|
|
588
|
+
function loadAllFor(ctx, sessionId, options = {}) {
|
|
589
|
+
return loadAllOlder(loadAllPortsFor(ctx, sessionId), options);
|
|
590
|
+
}
|
|
401
591
|
function createInject(ctx) {
|
|
402
592
|
return {
|
|
403
593
|
readQuestions: (sessionId) => {
|
|
@@ -417,7 +607,8 @@ window.__ModuleLoader__.load({
|
|
|
417
607
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
418
608
|
};
|
|
419
609
|
jumpToQuestion(ports, key);
|
|
420
|
-
}
|
|
610
|
+
},
|
|
611
|
+
loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options)
|
|
421
612
|
};
|
|
422
613
|
}
|
|
423
614
|
/**
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["useState","useRef","styles","createPortal"],"sources":["../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per user\n * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,\n * no native-title delay) shows the question's full text; clicking a dot scrolls\n * the chat to that question.\n *\n * Data arrives through the four props shares: the framework `useSessions`\n * hook (current session), the registrant inject face (read/subscribe/jump),\n * and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session (current loaded window). */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** Jump the chat to a question row. */\n jump: (sessionId: SessionId, key: string) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n text: string\n left: number\n top: number\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [questions, setQuestions] = useState<QuestionNode[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Refresh the question list whenever the current session or its content changes.\n useEffect(() => {\n if (!visible || current === undefined) {\n setQuestions([])\n return\n }\n const refresh = (): void => setQuestions(props.readQuestions(current))\n refresh()\n const unsubContent = props.subscribeContent(current, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n unsubContent()\n unsubList()\n }\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let retries = 0\n const applyLayout = (): void => {\n const panel = panelRef.current\n if (panel === null) return\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame === null || convRoot === null) return\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n if (convRect.height <= 0) {\n if (retries < 20) {\n retries += 1\n raf = requestAnimationFrame(applyLayout)\n }\n return\n }\n retries = 0\n panel.style.top = `${convRect.top - frameRect.top}px`\n panel.style.height = `${convRect.height}px`\n panel.style.left = `${convRect.left - frameRect.left}px`\n }\n applyLayout()\n raf = requestAnimationFrame(applyLayout)\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)\n const convRoot = findConvRoot()\n observer?.observe(convRoot ?? document.body, { box: 'border-box' })\n window.addEventListener('resize', applyLayout)\n return () => {\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', applyLayout)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (node: QuestionNode): void => {\n if (current === undefined) return\n setJumpingKey(node.key)\n props.jump(current, node.key)\n window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {questions.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{questions.length}</span>\n {questions.map((node) => (\n <button\n key={node.key}\n className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={node.text}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: node.text, left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(node)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.text}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-client-ui-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the right edge of the conversation column listing every\n * user question in the current session as a small button. Clicking a button\n * scrolls the chat to that question (paging older history when needed).\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqCA,MAAM,gBAAyD;GAC7D,eAAe;GACf,eAAe;GACf,WAAW;GACX,SAAS;EACX;EASA,SAAS,eAAmC;GAC1C,OAAO,SAAS,cAA2B,gDAA8C;EAC3F;EAEA,SAAgB,iBAAiB,OAAiD;GAChF,MAAM,UAAU,MAAM,aAAa,MAAM,EAAE,OAAO;GAClD,MAAM,UAAU,MAAM,aAAa,MAAO,EAAE,YAAY,KAAA,IAAY,KAAA,IAAY,EAAE,KAAK,EAAE,QAAS;GAClG,MAAM,UAAU,YAAY,KAAA,KAAa,YAAY,KAAA,KAAa,QAAQ,UAAU;GAEpF,MAAM,CAAC,WAAW,iBAAA,GAAgBA,MAAAA,SAAAA,CAAyB,CAAC,CAAC;GAC7D,MAAM,CAAC,YAAY,kBAAA,GAAiBA,MAAAA,SAAAA,CAAwB,IAAI;GAChE,MAAM,CAAC,MAAM,YAAA,GAAWA,MAAAA,SAAAA,CAAwB,IAAI;GACpD,MAAM,CAAC,SAAS,eAAA,GAAcA,MAAAA,SAAAA,CAA8B,IAAI;GAChE,MAAM,YAAA,GAAWC,MAAAA,OAAAA,CAA8B,IAAI;GACnD,MAAM,gBAAA,GAAeA,MAAAA,OAAAA,CAAsB,IAAI;GAE/C,MAAM,YAAY,YAA0B;IAC1C,QAAQ,OAAO;IACf,IAAI,aAAa,YAAY,MAAM,OAAO,aAAa,aAAa,OAAO;IAC3E,aAAa,UAAU,OAAO,iBAAiB,QAAQ,IAAI,GAAG,IAAI;GACpE;GAGA,CAAA,GAAA,MAAA,UAAA,OAAgB;IACd,IAAI,CAAC,WAAW,YAAY,KAAA,GAAW;KACrC,aAAa,CAAC,CAAC;KACf;IACF;IACA,MAAM,gBAAsB,aAAa,MAAM,cAAc,OAAO,CAAC;IACrE,QAAQ;IACR,MAAM,eAAe,MAAM,iBAAiB,SAAS,OAAO;IAC5D,MAAM,YAAY,MAAM,cAAc,OAAO;IAC7C,aAAa;KACX,aAAa;KACb,UAAU;IACZ;GACF,GAAG;IAAC;IAAS;IAAS;GAAK,CAAC;GAG5B,CAAA,GAAA,MAAA,UAAA,OAAgB;IACd,MAAM,gBAAgB,UAAuB;KAC3C,MAAM,OAAQ,MAAuC;KACrD,SAAS,MAAM,EAAE,cAAc,SAAS,cAAc,CAAC;IACzD;IACA,OAAO,iBAAiB,4BAA4B,YAAY;IAChE,aAAa,OAAO,oBAAoB,4BAA4B,YAAY;GAClF,GAAG,CAAC,KAAK,CAAC;GAIV,CAAA,GAAA,MAAA,gBAAA,OAAsB;IACpB,IAAI,CAAC,SAAS;IACd,IAAI,MAAM;IACV,IAAI,UAAU;IACd,MAAM,oBAA0B;KAC9B,MAAM,QAAQ,SAAS;KACvB,IAAI,UAAU,MAAM;KACpB,MAAM,QAAQ,MAAM,QAAQ,sBAAsB,CAAC,EAAE,iBAAiB;KACtE,MAAM,WAAW,aAAa;KAC9B,IAAI,UAAU,QAAQ,aAAa,MAAM;KACzC,MAAM,YAAY,MAAM,sBAAsB;KAC9C,MAAM,WAAW,SAAS,sBAAsB;KAChD,IAAI,SAAS,UAAU,GAAG;MACxB,IAAI,UAAU,IAAI;OAChB,WAAW;OACX,MAAM,sBAAsB,WAAW;MACzC;MACA;KACF;KACA,UAAU;KACV,MAAM,MAAM,MAAM,GAAG,SAAS,MAAM,UAAU,IAAI;KAClD,MAAM,MAAM,SAAS,GAAG,SAAS,OAAO;KACxC,MAAM,MAAM,OAAO,GAAG,SAAS,OAAO,UAAU,KAAK;IACvD;IACA,YAAY;IACZ,MAAM,sBAAsB,WAAW;IACvC,MAAM,WAAW,OAAO,mBAAmB,cAAc,OAAO,IAAI,eAAe,WAAW;IAC9F,MAAM,WAAW,aAAa;IAC9B,UAAU,QAAQ,YAAY,SAAS,MAAM,EAAE,KAAK,aAAa,CAAC;IAClE,OAAO,iBAAiB,UAAU,WAAW;IAC7C,aAAa;KACX,IAAI,QAAQ,GAAG,qBAAqB,GAAG;KACvC,UAAU,WAAW;KACrB,OAAO,oBAAoB,UAAU,WAAW;IAClD;GACF,GAAG,CAAC,OAAO,CAAC;GAGZ,CAAA,GAAA,MAAA,UAAA,aAAsB;IACpB,IAAI,aAAa,YAAY,MAAM,OAAO,aAAa,aAAa,OAAO;GAC7E,GAAG,CAAC,CAAC;GAEL,IAAI,CAAC,SAAS,OAAO;GAErB,MAAM,UAAU,SAA6B;IAC3C,IAAI,YAAY,KAAA,GAAW;IAC3B,cAAc,KAAK,GAAG;IACtB,MAAM,KAAK,SAAS,KAAK,GAAG;IAC5B,OAAO,iBAAiB,eAAe,MAAO,MAAM,KAAK,MAAM,OAAO,CAAE,GAAG,GAAG;GAChF;GAEA,MAAM,IAAI,MAAM;GAEhB,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IAAK,KAAK;IAAU,WAAWC,gCAAO;IAAM,qBAAkB;IAA9D,UAAA;KACG,SAAS,OAAO,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWA,gCAAO;MAAM,MAAK;MAAU,UAAA;KAAU,CAAA,IAAI;KAC3E,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWA,gCAAO;MACpB,UAAA,UAAU,WAAW,IACpB,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;OAAK,WAAWA,gCAAO;OAAQ,UAAA,EAAE,aAAa;MAAO,CAAA,IAErD,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;OAAK,WAAWA,gCAAO;OAAvB,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;QAAM,WAAWA,gCAAO;QAAQ,UAAA,UAAU;OAAa,CAAA,GACtD,UAAU,KAAK,SACd,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;QAEE,WAAW,eAAe,KAAK,MAAM,GAAGA,gCAAO,IAAI,GAAGA,gCAAO,WAAWA,gCAAO;QAC/E,cAAY,KAAK;QACjB,eAAe,MAAM;SACnB,MAAM,IAAI,EAAE,cAAc,sBAAsB;SAChD,WAAW;UAAE,MAAM,KAAK;UAAM,MAAM,EAAE,QAAQ;UAAI,KAAK,EAAE;SAAI,CAAC;QAChE;QACA,oBAAoB,WAAW,IAAI;QACnC,eAAe,OAAO,IAAI;OAC3B,GATM,KAAK,GASX,CACF,CACE;;KAEJ,CAAA;KACJ,YAAY,QAAA,GACTC,UAAAA,aAAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;MAAK,WAAWD,gCAAO;MAAS,OAAO;OAAE,MAAM,QAAQ;OAAM,KAAK,QAAQ;MAAI;MAC3E,UAAA,QAAQ;KACN,CAAA,GACL,SAAS,IACX,IACA;IACD;;EAET;;;;;;;ECzLA,MAAa,KAAK;GAChB,eAAe;GACf,iBAAiB;GACjB,eAAe;GACf,iBAAiB;GACjB,gBAAgB;EAClB;EAEA,MAAa,KAAK;GAChB,eAAe;GACf,iBAAiB;GACjB,eAAe;GACf,iBAAiB;GACjB,gBAAgB;EAClB;;;;ECaA,MAAa,iBAAiB,CAAC,QAAQ,UAAU;EASjD,SAAS,SAAS,MAAyC;GACzD,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM,OAAO,KAAA;GACtD,OAAO;EACT;;EAGA,SAAgB,YAAY,SAA0E;GACpG,IAAI,YAAY,KAAA,KAAa,QAAQ,WAAW,GAAG,OAAO;GAC1D,MAAM,QAAQ,QAAQ;GACtB,IAAI,OAAO,OAAO,SAAS,UAAU,OAAO,MAAM;GAClD,OAAO;EACT;;EAGA,SAAgB,iBAAiB,OAA+C;GAC9E,MAAM,MAAsB,CAAC;GAC7B,KAAK,MAAM,QAAQ,OAAO;IACxB,IAAI,CAAC,eAAe,SAAS,KAAK,IAAuC,GAAG;IAC5E,MAAM,UAAU,SAAS,KAAK,IAAI;IAClC,IAAI,KAAK;KACP,KAAK,KAAK;KACV,WAAW,KAAK;KAChB,KAAK,SAAS,OAAO;KACrB,MAAM,SAAS,QAAQ;KAEvB,MAAM,YAAY,SAAS,OAAO;IACpC,CAAC;GACH;GACA,IAAI,MAAM,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS;GAC5C,OAAO;EACT;;EAgBA,SAAgB,kBACd,OACA,YAC2C;GAC3C,IAAI,OAAkD;GACtD,KAAK,MAAM,QAAQ,OAAO;IACxB,IAAI,KAAK,eAAe,UAAU;IAClC,IAAI,KAAK,QAAQ,YAAY;IAC7B,IAAI,SAAS,QAAQ,KAAK,YAAY,KAAK,WAAW,OAAO;KAAE,KAAK,KAAK;KAAK,WAAW,KAAK;IAAU;GAC1G;GACA,OAAO;EACT;;;;;;;;;ECxCA,MAAM,WAAW;GACf,gBAAgB;GAChB,UAAU;GACV,WAAW;GACX,QAAQ;EACV;EAEA,SAAS,aAAa,MAAsD;GAC1E,IAAI,MAAqB;GACzB,KAAK,MAAM,OAAO,MAChB,IAAI,QAAQ,QAAQ,IAAI,YAAY,KAAK,MAAM,IAAI;GAErD,OAAO;EACT;EAEA,SAAS,WAAW,MAA+G;GACjI,MAAM,MAA4C,CAAC;GACnD,KAAK,MAAM,OAAO,MAAM;IACtB,IAAI,IAAI,eAAe,UAAU;IACjC,IAAI,KAAK;KAAE,KAAK,IAAI;KAAK,WAAW,IAAI;IAAU,CAAC;GACrD;GACA,OAAO;EACT;;;;;;EAOA,eAAsB,eAAe,OAAkB,KAAa,UAAuB,CAAC,GAAwB;GAClH,MAAM,MAAM;IAAE,GAAG;IAAU,GAAG;GAAQ;GACtC,MAAM,QAAQ,MAAuB,WAAW,UAAsB;IACpE,MAAM,SAAS,MAAM,QAAQ;IAC7B,OAAO,WAAW;KAAE,IAAI;KAAO;KAAM,UAAU;IAAK,IAAI;KAAE,IAAI;KAAO;IAAK;GAC5E;GAEA,IAAI,CAAC,MAAM,aAAa,GAAG,OAAO,KAAK,eAAe;GAEtD,MAAM,WAAW,MAAM,IAAI,IAAI,IAAI;GACnC,IAAI,QAAQ;GAGZ,OAAO,MAAM;IACX,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,SAAS,KAAA,GAAW,OAAO,KAAK,eAAe;IACnD,MAAM,OAAO,WAAW,KAAK,IAAI;IACjC,IAAI,KAAK,MAAM,MAAM,EAAE,QAAQ,GAAG,GAAG;IACrC,IAAI,KAAK,cAAc,QAAQ;KAC7B,IAAI,KAAK,cAAc,WAAW,MAAM,IAAI,IAAI,UAC9C,OAAO,KAAK,KAAK,cAAc,UAAU,kBAAkB,SAAS;KAEtE,MAAM,MAAM,MAAM,IAAI,MAAM;KAC5B;IACF;IACA,IAAI,KAAK,YAAY,MAAM,OAAO,KAAK,WAAW;IAClD,IAAI,SAAS,IAAI,YAAY,MAAM,IAAI,IAAI,UAAU,OAAO,KAAK,SAAS;IAC1E,IAAI,KAAK,cAAc;KACrB,MAAM,MAAM,MAAM,IAAI,MAAM;KAC5B;IACF;IACA,MAAM,SAAS,aAAa,IAAI;IAChC,MAAM,MAAM,UAAU;IACtB,SAAS;IACT,MAAM,YAAY,MAAM,SAAS;IACjC,MAAM,QAAQ,aAAa,cAAc,KAAA,IAAY,CAAC,IAAI,UAAU,IAAI;IACxE,IAAI,UAAU,QAAS,WAAW,QAAQ,SAAS,QAAS,OAAO,KAAK,WAAW;GACrF;GAGA,MAAM,YAAY,OAAO,WAAgD;IACvE,KAAK,IAAI,SAAS,GAAG,UAAU,IAAI,WAAW,UAAU,IAAI,QAAQ;KAClE,IAAI,CAAC,MAAM,aAAa,GAAG,OAAO;KAClC,MAAM,MAAM,MAAM,QAAQ,MAAM;KAChC,IAAI,QAAQ,MAAM,OAAO;KACzB,MAAM,MAAM,MAAM,IAAI,MAAM;IAC9B;IACA,OAAO;GACT;GAEA,MAAM,MAAM,MAAM,UAAU,GAAG;GAC/B,IAAI,QAAQ,MAAM;IAChB,MAAM,eAAe,GAAG;IACxB,OAAO,EAAE,IAAI,KAAK;GACpB;GAEA,MAAM,OAAO,MAAM,SAAS;GAC5B,MAAM,WAAW,kBAAkB,SAAS,KAAA,IAAY,CAAC,IAAI,KAAK,MAAM,GAAG;GAC3E,IAAI,aAAa,MAAM;IACrB,MAAM,QAAQ,MAAM,UAAU,SAAS,GAAG;IAC1C,IAAI,UAAU,MAAM;KAClB,MAAM,eAAe,KAAK;KAC1B,OAAO,KAAK,iBAAiB,IAAI;IACnC;GACF;GACA,OAAO,KAAK,iBAAiB,KAAK;EACpC;;;;ECjIA,MAAM,KAAK;;EAUX,MAAa,SAAS;GAAC;GAAS;GAAU;EAAU;EAQpD,SAAS,aAAsB;GAC7B,IAAI,WAAW,4BAA4B,MAAM,OAAO;GACxD,WAAW,0BAA0B;GACrC,OAAO;EACT;EAEA,SAAS,eAAqB;GAC5B,WAAW,0BAA0B,KAAA;EACvC;;EAGA,SAAS,aAAa,KAAoB,WAAiC;GACzE,OAAO;IACL,gBAAgB;KAEd,MAAM,OADU,IAAI,SAAS,QAAQ,SAClB,CAAC,EAAE,QAAQ,YAAY;KAC1C,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;KAC/B,OAAO;MACL,WAAW,KAAK;MAChB,SAAS,KAAK;MACd,cAAc,KAAK;MACnB,MAAM,KAAK,KAAK,MAAM,OAAO;KAC/B;IACF;IACA,WAAW,YAAY;KACrB,MAAM,UAAU,IAAI,SAAS,QAAQ,SAAS;KAC9C,IAAI,YAAY,KAAA,GAAW,MAAM,IAAI,MAAM,qBAAqB;KAChE,MAAM,QAAQ,QAAQ,UAAU;IAClC;IACA,oBAAoB,SAAS,cAAc,kBAAkB,MAAM;IACnE,UAAU,QAAgB;KACxB,KAAK,MAAM,aAAa,MAAM,KAAK,SAAS,iBAA8B,wBAAwB,CAAC,GACjG,IAAI,UAAU,QAAQ,kBAAkB,KAAK,OAAO;KAEtD,OAAO;IACT;IACA,iBAAiB,QAAQ,IAAI,eAAe,EAAE,OAAO,QAAQ,CAAC;IAC9D,WAAW,KAAK,IAAI;IACpB,QAAQ,OAAO,IAAI,SAAS,YAAY,OAAO,WAAW,SAAS,EAAE,CAAC;GACxE;EACF;EAEA,SAAS,aAAa,KAAyC;GAC7D,OAAO;IACL,gBAAgB,cAAc;KAC5B,MAAM,OAAO,IAAI,SAAS,QAAQ,SAAS,CAAC,EAAE,QAAQ,YAAY;KAClE,IAAI,SAAS,KAAA,GAAW,OAAO,CAAC;KAChC,OAAO,iBAAiB,KAAK,KAAK,MAAM,OAAO,CAAC;IAClD;IACA,gBAAgB,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE;IACrD,mBAAmB,WAAW,OAAO;KACnC,MAAM,UAAU,IAAI,SAAS,QAAQ,SAAS;KAC9C,IAAI,YAAY,KAAA,GAAW,aAAa,CAAC;KACzC,OAAO,QAAQ,QAAQ,UAAU,EAAE;IACrC;IACA,OAAO,WAAW,QAAQ;KACxB,MAAM,QAAQ,aAAa,KAAK,SAAS;KACzC,MAAM,UAAU,SAA0B;MAGxC,OAAO,cAAc,IAAI,YAAY,4BAA4B,EAAE,QAAQ,KAAK,CAAC,CAAC;KACpF;KACA,eAAoB,OAAO,GAAG;IAChC;GACF;EACF;;;;;EAMA,SAAgB,MAAM,KAA0B;GAC9C,IAAI,CAAC,WAAW,GAAG;GACnB,IAAI,aAAa,cAAc,2BAA2B;GAE1D,IAAI,aAAa,IAAI,OAAO,SAAS,IAAI;IAAE;IAAI;GAAG,CAAC,GAAG,4BAA4B;GAElF,MAAM,WAAW,aAAa,GAAG;GAEjC,IAAI,MAAM,OAAO,uBAAuB,IAAI,MAAM,SAAS;IACzD,MAAM;IACN,IAAI;IACJ,OAAO;IACP,QAAQ;IACR,cAAc;GAChB,GAAG,gBAAgB,CAAC;EACtB"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["useState","useRef","styles","createPortal","DEFAULTS"],"sources":["../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/core/load-all.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per user\n * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,\n * no native-title delay) shows the question's full text; clicking a dot scrolls\n * the chat to that question.\n *\n * Dots index the WHOLE session history, not just the currently loaded window:\n * on show, the strip auto-expands older pages (`loadAllOlder`) so questions\n * that still sit behind DSH's \"load older\" button are surfaced too. While the\n * expansion is running the count shows a \"…\" affordance; if the safety budget\n * is exhausted a dimmed \"load earlier\" dot appears above the oldest question.\n *\n * Data arrives through the four props shares: the framework `useSessions`\n * hook (current session), the registrant inject face (read/subscribe/jump/\n * load-all), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { LoadAllOptions, LoadAllResult } from '../core/load-all.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session (current loaded window). */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** Jump the chat to a question row. */\n jump: (sessionId: SessionId, key: string) => void\n /** Expand the session history until every question is loaded. */\n loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n text: string\n left: number\n top: number\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [questions, setQuestions] = useState<QuestionNode[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const [loadingAll, setLoadingAll] = useState(false)\n const [moreAvailable, setMoreAvailable] = useState(false)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n /** Abort controller for the in-flight expansion (cancelled on session change). */\n const loadAllAbortRef = useRef<AbortController | null>(null)\n /** Session whose expansion is already running, to avoid duplicate loops. */\n const loadingAllSessionRef = useRef<SessionId | null>(null)\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Refresh the question list whenever the current session or its content changes.\n useEffect(() => {\n if (!visible || current === undefined) {\n setQuestions([])\n return\n }\n const refresh = (): void => setQuestions(props.readQuestions(current))\n refresh()\n const unsubContent = props.subscribeContent(current, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n unsubContent()\n unsubList()\n }\n }, [visible, current, props])\n\n // Auto-expand the full history so collapsed older questions surface as dots.\n // Runs once per session; the session notifier drives the list refresh above.\n useEffect(() => {\n if (!visible || current === undefined) {\n loadAllAbortRef.current?.abort()\n loadAllAbortRef.current = null\n loadingAllSessionRef.current = null\n setLoadingAll(false)\n setMoreAvailable(false)\n return\n }\n if (loadingAllSessionRef.current === current) return\n loadingAllSessionRef.current = current\n const controller = new AbortController()\n loadAllAbortRef.current = controller\n setLoadingAll(true)\n setMoreAvailable(false)\n props.loadAllOlder(current, { signal: controller.signal })\n .then((result) => {\n // Budget exhausted but more history still exists: offer \"load earlier\".\n setMoreAvailable(result.code === 'BUDGET' && !result.ok)\n })\n .finally(() => {\n setLoadingAll(false)\n if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null\n loadingAllSessionRef.current = null\n })\n return () => {\n controller.abort()\n }\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let retries = 0\n const applyLayout = (): void => {\n const panel = panelRef.current\n if (panel === null) return\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame === null || convRoot === null) return\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n if (convRect.height <= 0) {\n if (retries < 20) {\n retries += 1\n raf = requestAnimationFrame(applyLayout)\n }\n return\n }\n retries = 0\n panel.style.top = `${convRect.top - frameRect.top}px`\n panel.style.height = `${convRect.height}px`\n panel.style.left = `${convRect.left - frameRect.left}px`\n }\n applyLayout()\n raf = requestAnimationFrame(applyLayout)\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)\n const convRoot = findConvRoot()\n observer?.observe(convRoot ?? document.body, { box: 'border-box' })\n window.addEventListener('resize', applyLayout)\n return () => {\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', applyLayout)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (node: QuestionNode): void => {\n if (current === undefined) return\n setJumpingKey(node.key)\n props.jump(current, node.key)\n window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)\n }\n\n const onLoadMore = (): void => {\n if (current === undefined) return\n setMoreAvailable(false)\n setLoadingAll(true)\n props.loadAllOlder(current)\n .then((result) => {\n setMoreAvailable(result.code === 'BUDGET' && !result.ok)\n })\n .finally(() => setLoadingAll(false))\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {questions.length === 0 ? (\n <div className={styles.empty}>{loadingAll ? t('strip.loadingAll') : t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>\n {questions.length}\n {loadingAll ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}\n </span>\n {moreAvailable && !loadingAll ? (\n <button\n className={`${styles.dot} ${styles.moreDot}`}\n aria-label={t('strip.loadEarlier')}\n title={t('strip.loadEarlier')}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: t('strip.loadEarlier'), left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={onLoadMore}\n />\n ) : null}\n {questions.map((node) => (\n <button\n key={node.key}\n className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={node.text}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: node.text, left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(node)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.text}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'strip.loadingAll': '正在加载全部历史…',\n 'strip.loadingSuffix': '…',\n 'strip.loadEarlier': '加载更早的问题',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'strip.loadingAll': 'Loading full history…',\n 'strip.loadingSuffix': '…',\n 'strip.loadEarlier': 'Load earlier questions',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Load-all orchestration for the question-nav strip.\n *\n * DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds\n * the currently loaded window, and questions that still sit behind the \"load\n * older\" button are invisible to the strip until the window is expanded\n * backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole\n * history is materialized), so every user question becomes a dot.\n *\n * Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view\n * liveness, clocks) so it is unit-testable without a browser or session.\n */\n\nexport interface LoadAllSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n}\n\nexport interface LoadAllPorts {\n /** Read the current session snapshot; undefined when unavailable. */\n snapshot: () => LoadAllSnapshot | undefined\n /** Expand the window backwards by one page (may preserve scroll). */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n}\n\nexport interface LoadAllOptions {\n /** Max older pages to fetch before giving up (default 400). */\n maxPages?: number\n /** Total wall-clock budget for the whole expansion (default 60s). */\n totalTimeoutMs?: number\n /** Poll interval for open/loading transitions (default 60ms). */\n pollMs?: number\n /** Abort the expansion; checked every iteration. */\n signal?: AbortSignal\n}\n\nexport type LoadAllCode =\n | 'COMPLETE'\n | 'VIEW_INACTIVE'\n | 'NOT_OPEN'\n | 'BUDGET'\n | 'TIMEOUT'\n | 'CANCELLED'\n\nexport interface LoadAllResult {\n ok: boolean\n code: LoadAllCode\n /** Number of `loadOlder` pages actually fetched. */\n pages: number\n}\n\nconst DEFAULTS = {\n maxPages: 400,\n totalTimeoutMs: 60_000,\n pollMs: 60,\n}\n\n/**\n * Expand the session window backwards until the earliest history is loaded.\n * Waits while the session is still opening; aborts on cancellation, budget or\n * timeout. Safe to re-enter: once `hasMore` is false the loop returns\n * immediately with `COMPLETE`.\n */\nexport async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {\n const cfg = { ...DEFAULTS, ...options }\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n const cancelled = (): boolean => cfg.signal?.aborted === true\n\n while (true) {\n if (cancelled()) return { ok: false, code: 'CANCELLED', pages }\n if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }\n const snap = ports.snapshot()\n if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }\n if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }\n // Nothing older left: the whole history is in the window.\n if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }\n if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }\n if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }\n // Wait while the session is still opening or a page is already in flight\n // (a user-initiated \"load older\" click shares this same gate).\n if (snap.openState !== 'open' || snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n await ports.loadOlder()\n pages += 1\n }\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button. Clicking a button\n * scrolls the chat to that question (paging older history when needed). The\n * strip auto-expands the whole session history so even collapsed older\n * questions are surfaced as dots.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\nimport { loadAllOlder, type LoadAllOptions, type LoadAllResult } from '../core/load-all.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/** Resolve the active conversation scrollport (or null when not mounted). */\nfunction scrollport(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-conversation-scroll]')\n}\n\n/**\n * One backward page that preserves the reader's scroll position. DSH's own\n * \"load older\" button arms a paging anchor; a programmatic `loadOlder()` does\n * not, so without this compensation prepended content would push the visible\n * rows down. We restore by the exact growth of the scrollHeight.\n */\nasync function pagedLoadOlder(ctx: ClientContext, sessionId: SessionId): Promise<void> {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return\n const port = scrollport()\n const beforeHeight = port?.scrollHeight ?? 0\n const beforeTop = port?.scrollTop ?? 0\n await binding.session.loadOlder()\n if (port === null) return\n // Let React commit the prepend before measuring the new height.\n await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))\n const delta = port.scrollHeight - beforeHeight\n if (delta > 0) port.scrollTop = beforeTop + delta\n}\n\n/** Map the session to the load-all port surface. */\nfunction loadAllPortsFor(ctx: ClientContext, sessionId: SessionId): Parameters<typeof loadAllOlder>[0] {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return { openState: snap.openState, hasMore: snap.hasMore, loadingOlder: snap.loadingOlder }\n },\n loadOlder: () => pagedLoadOlder(ctx, sessionId),\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/** Expand the whole session history so every question becomes a dot. */\nfunction loadAllFor(ctx: ClientContext, sessionId: SessionId, options: LoadAllOptions = {}): Promise<LoadAllResult> {\n return loadAllOlder(loadAllPortsFor(ctx, sessionId), options)\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
2
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
3
3
|
import type { QuestionNode } from '../core/nodes.ts';
|
|
4
|
+
import type { LoadAllOptions, LoadAllResult } from '../core/load-all.ts';
|
|
4
5
|
/** Values the registrant inject face supplies (wired in src/client/index.ts). */
|
|
5
6
|
export interface QuestionNavInjected {
|
|
6
7
|
/** Extract the user questions of a session (current loaded window). */
|
|
@@ -11,6 +12,8 @@ export interface QuestionNavInjected {
|
|
|
11
12
|
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void;
|
|
12
13
|
/** Jump the chat to a question row. */
|
|
13
14
|
jump: (sessionId: SessionId, key: string) => void;
|
|
15
|
+
/** Expand the session history until every question is loaded. */
|
|
16
|
+
loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>;
|
|
14
17
|
}
|
|
15
18
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>;
|
|
16
19
|
export declare function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-half entry for the dsh-
|
|
2
|
+
* Browser-half entry for the dsh-question-nav plugin.
|
|
3
3
|
*
|
|
4
4
|
* Registers one surface into the frame-wide floating layer (`shell.overlay`):
|
|
5
|
-
* a vertical strip on the
|
|
5
|
+
* a vertical strip on the LEFT edge of the conversation column listing every
|
|
6
6
|
* user question in the current session as a small button. Clicking a button
|
|
7
|
-
* scrolls the chat to that question (paging older history when needed).
|
|
7
|
+
* scrolls the chat to that question (paging older history when needed). The
|
|
8
|
+
* strip auto-expands the whole session history so even collapsed older
|
|
9
|
+
* questions are surfaced as dots.
|
|
8
10
|
*
|
|
9
11
|
* Failure policy: nothing here throws at apply time — an external plugin must
|
|
10
12
|
* never take the GUI down.
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const zh: {
|
|
6
6
|
readonly 'strip.empty': "本会话还没有提问";
|
|
7
|
+
readonly 'strip.loadingAll': "正在加载全部历史…";
|
|
8
|
+
readonly 'strip.loadingSuffix': "…";
|
|
9
|
+
readonly 'strip.loadEarlier': "加载更早的问题";
|
|
7
10
|
readonly 'jump.inactive': "聊天视图未激活";
|
|
8
11
|
readonly 'jump.hidden': "目标无独立气泡,已定位到邻近内容";
|
|
9
12
|
readonly 'jump.notfound': "目标未加载或不存在(可能已压缩)";
|
|
@@ -11,6 +14,9 @@ export declare const zh: {
|
|
|
11
14
|
};
|
|
12
15
|
export declare const en: {
|
|
13
16
|
readonly 'strip.empty': "No questions in this session yet";
|
|
17
|
+
readonly 'strip.loadingAll': "Loading full history…";
|
|
18
|
+
readonly 'strip.loadingSuffix': "…";
|
|
19
|
+
readonly 'strip.loadEarlier': "Load earlier questions";
|
|
14
20
|
readonly 'jump.inactive': "Chat view is not active";
|
|
15
21
|
readonly 'jump.hidden': "No dedicated bubble; landed on nearby content";
|
|
16
22
|
readonly 'jump.notfound': "Target not loaded or missing (maybe compacted)";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-all orchestration for the question-nav strip.
|
|
3
|
+
*
|
|
4
|
+
* DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
|
|
5
|
+
* the currently loaded window, and questions that still sit behind the "load
|
|
6
|
+
* older" button are invisible to the strip until the window is expanded
|
|
7
|
+
* backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
|
|
8
|
+
* history is materialized), so every user question becomes a dot.
|
|
9
|
+
*
|
|
10
|
+
* Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
|
|
11
|
+
* liveness, clocks) so it is unit-testable without a browser or session.
|
|
12
|
+
*/
|
|
13
|
+
export interface LoadAllSnapshot {
|
|
14
|
+
openState: string;
|
|
15
|
+
hasMore: boolean;
|
|
16
|
+
loadingOlder: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface LoadAllPorts {
|
|
19
|
+
/** Read the current session snapshot; undefined when unavailable. */
|
|
20
|
+
snapshot: () => LoadAllSnapshot | undefined;
|
|
21
|
+
/** Expand the window backwards by one page (may preserve scroll). */
|
|
22
|
+
loadOlder: () => Promise<void>;
|
|
23
|
+
/** True while the chat view is active (a `[data-chat-flow]` is mounted). */
|
|
24
|
+
isViewActive: () => boolean;
|
|
25
|
+
/** Monotonic ms clock. */
|
|
26
|
+
now: () => number;
|
|
27
|
+
/** Async sleep. */
|
|
28
|
+
sleep: (ms: number) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface LoadAllOptions {
|
|
31
|
+
/** Max older pages to fetch before giving up (default 400). */
|
|
32
|
+
maxPages?: number;
|
|
33
|
+
/** Total wall-clock budget for the whole expansion (default 60s). */
|
|
34
|
+
totalTimeoutMs?: number;
|
|
35
|
+
/** Poll interval for open/loading transitions (default 60ms). */
|
|
36
|
+
pollMs?: number;
|
|
37
|
+
/** Abort the expansion; checked every iteration. */
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
export type LoadAllCode = 'COMPLETE' | 'VIEW_INACTIVE' | 'NOT_OPEN' | 'BUDGET' | 'TIMEOUT' | 'CANCELLED';
|
|
41
|
+
export interface LoadAllResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
code: LoadAllCode;
|
|
44
|
+
/** Number of `loadOlder` pages actually fetched. */
|
|
45
|
+
pages: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Expand the session window backwards until the earliest history is loaded.
|
|
49
|
+
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
50
|
+
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
51
|
+
* immediately with `COMPLETE`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function loadAllOlder(ports: LoadAllPorts, options?: LoadAllOptions): Promise<LoadAllResult>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luziyang2026/dsh-question-nav",
|
|
3
3
|
"description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the left edge of the conversation column, one dot per user question — hover enlarges and shows the full question text, click jumps to that message.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.7.0",
|
|
7
7
|
"engines": {
|
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
7
7
|
* the chat to that question.
|
|
8
8
|
*
|
|
9
|
+
* Dots index the WHOLE session history, not just the currently loaded window:
|
|
10
|
+
* on show, the strip auto-expands older pages (`loadAllOlder`) so questions
|
|
11
|
+
* that still sit behind DSH's "load older" button are surfaced too. While the
|
|
12
|
+
* expansion is running the count shows a "…" affordance; if the safety budget
|
|
13
|
+
* is exhausted a dimmed "load earlier" dot appears above the oldest question.
|
|
14
|
+
*
|
|
9
15
|
* Data arrives through the four props shares: the framework `useSessions`
|
|
10
|
-
* hook (current session), the registrant inject face (read/subscribe/jump
|
|
11
|
-
* and the bound locale translator.
|
|
16
|
+
* hook (current session), the registrant inject face (read/subscribe/jump/
|
|
17
|
+
* load-all), and the bound locale translator.
|
|
12
18
|
*/
|
|
13
19
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
14
20
|
import { createPortal } from 'react-dom'
|
|
@@ -18,6 +24,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
18
24
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
19
25
|
import type { QuestionNode } from '../core/nodes.ts'
|
|
20
26
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
27
|
+
import type { LoadAllOptions, LoadAllResult } from '../core/load-all.ts'
|
|
21
28
|
import type { QuestionNavKey } from './locales.ts'
|
|
22
29
|
import styles from './question-nav.module.css'
|
|
23
30
|
|
|
@@ -31,6 +38,8 @@ export interface QuestionNavInjected {
|
|
|
31
38
|
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void
|
|
32
39
|
/** Jump the chat to a question row. */
|
|
33
40
|
jump: (sessionId: SessionId, key: string) => void
|
|
41
|
+
/** Expand the session history until every question is loaded. */
|
|
42
|
+
loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -62,8 +71,14 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
62
71
|
const [jumpingKey, setJumpingKey] = useState<string | null>(null)
|
|
63
72
|
const [hint, setHint] = useState<string | null>(null)
|
|
64
73
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
|
74
|
+
const [loadingAll, setLoadingAll] = useState(false)
|
|
75
|
+
const [moreAvailable, setMoreAvailable] = useState(false)
|
|
65
76
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
66
77
|
const hintTimerRef = useRef<number | null>(null)
|
|
78
|
+
/** Abort controller for the in-flight expansion (cancelled on session change). */
|
|
79
|
+
const loadAllAbortRef = useRef<AbortController | null>(null)
|
|
80
|
+
/** Session whose expansion is already running, to avoid duplicate loops. */
|
|
81
|
+
const loadingAllSessionRef = useRef<SessionId | null>(null)
|
|
67
82
|
|
|
68
83
|
const showHint = (message: string): void => {
|
|
69
84
|
setHint(message)
|
|
@@ -87,6 +102,38 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
87
102
|
}
|
|
88
103
|
}, [visible, current, props])
|
|
89
104
|
|
|
105
|
+
// Auto-expand the full history so collapsed older questions surface as dots.
|
|
106
|
+
// Runs once per session; the session notifier drives the list refresh above.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!visible || current === undefined) {
|
|
109
|
+
loadAllAbortRef.current?.abort()
|
|
110
|
+
loadAllAbortRef.current = null
|
|
111
|
+
loadingAllSessionRef.current = null
|
|
112
|
+
setLoadingAll(false)
|
|
113
|
+
setMoreAvailable(false)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (loadingAllSessionRef.current === current) return
|
|
117
|
+
loadingAllSessionRef.current = current
|
|
118
|
+
const controller = new AbortController()
|
|
119
|
+
loadAllAbortRef.current = controller
|
|
120
|
+
setLoadingAll(true)
|
|
121
|
+
setMoreAvailable(false)
|
|
122
|
+
props.loadAllOlder(current, { signal: controller.signal })
|
|
123
|
+
.then((result) => {
|
|
124
|
+
// Budget exhausted but more history still exists: offer "load earlier".
|
|
125
|
+
setMoreAvailable(result.code === 'BUDGET' && !result.ok)
|
|
126
|
+
})
|
|
127
|
+
.finally(() => {
|
|
128
|
+
setLoadingAll(false)
|
|
129
|
+
if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null
|
|
130
|
+
loadingAllSessionRef.current = null
|
|
131
|
+
})
|
|
132
|
+
return () => {
|
|
133
|
+
controller.abort()
|
|
134
|
+
}
|
|
135
|
+
}, [visible, current, props])
|
|
136
|
+
|
|
90
137
|
// Listen for jump-failure events and surface the hint.
|
|
91
138
|
useEffect(() => {
|
|
92
139
|
const onJumpFailed = (event: Event): void => {
|
|
@@ -150,6 +197,17 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
150
197
|
window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)
|
|
151
198
|
}
|
|
152
199
|
|
|
200
|
+
const onLoadMore = (): void => {
|
|
201
|
+
if (current === undefined) return
|
|
202
|
+
setMoreAvailable(false)
|
|
203
|
+
setLoadingAll(true)
|
|
204
|
+
props.loadAllOlder(current)
|
|
205
|
+
.then((result) => {
|
|
206
|
+
setMoreAvailable(result.code === 'BUDGET' && !result.ok)
|
|
207
|
+
})
|
|
208
|
+
.finally(() => setLoadingAll(false))
|
|
209
|
+
}
|
|
210
|
+
|
|
153
211
|
const t = props.t
|
|
154
212
|
|
|
155
213
|
return (
|
|
@@ -157,10 +215,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
157
215
|
{hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
|
|
158
216
|
<div className={styles.list}>
|
|
159
217
|
{questions.length === 0 ? (
|
|
160
|
-
<div className={styles.empty}>{t('strip.empty')}</div>
|
|
218
|
+
<div className={styles.empty}>{loadingAll ? t('strip.loadingAll') : t('strip.empty')}</div>
|
|
161
219
|
) : (
|
|
162
220
|
<div className={styles.dots}>
|
|
163
|
-
<span className={styles.count}>
|
|
221
|
+
<span className={styles.count}>
|
|
222
|
+
{questions.length}
|
|
223
|
+
{loadingAll ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}
|
|
224
|
+
</span>
|
|
225
|
+
{moreAvailable && !loadingAll ? (
|
|
226
|
+
<button
|
|
227
|
+
className={`${styles.dot} ${styles.moreDot}`}
|
|
228
|
+
aria-label={t('strip.loadEarlier')}
|
|
229
|
+
title={t('strip.loadEarlier')}
|
|
230
|
+
onMouseEnter={(e) => {
|
|
231
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
232
|
+
setTooltip({ text: t('strip.loadEarlier'), left: r.right + 10, top: r.top })
|
|
233
|
+
}}
|
|
234
|
+
onMouseLeave={() => setTooltip(null)}
|
|
235
|
+
onClick={onLoadMore}
|
|
236
|
+
/>
|
|
237
|
+
) : null}
|
|
164
238
|
{questions.map((node) => (
|
|
165
239
|
<button
|
|
166
240
|
key={node.key}
|
package/src/client/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-half entry for the dsh-
|
|
2
|
+
* Browser-half entry for the dsh-question-nav plugin.
|
|
3
3
|
*
|
|
4
4
|
* Registers one surface into the frame-wide floating layer (`shell.overlay`):
|
|
5
|
-
* a vertical strip on the
|
|
5
|
+
* a vertical strip on the LEFT edge of the conversation column listing every
|
|
6
6
|
* user question in the current session as a small button. Clicking a button
|
|
7
|
-
* scrolls the chat to that question (paging older history when needed).
|
|
7
|
+
* scrolls the chat to that question (paging older history when needed). The
|
|
8
|
+
* strip auto-expands the whole session history so even collapsed older
|
|
9
|
+
* questions are surfaced as dots.
|
|
8
10
|
*
|
|
9
11
|
* Failure policy: nothing here throws at apply time — an external plugin must
|
|
10
12
|
* never take the GUI down.
|
|
@@ -19,6 +21,7 @@ import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.t
|
|
|
19
21
|
import { en, zh, type QuestionNavKey } from './locales.ts'
|
|
20
22
|
import { extractQuestions } from '../core/nodes.ts'
|
|
21
23
|
import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
|
|
24
|
+
import { loadAllOlder, type LoadAllOptions, type LoadAllResult } from '../core/load-all.ts'
|
|
22
25
|
|
|
23
26
|
/** Locale namespace this plugin owns. */
|
|
24
27
|
const NS = 'question-nav'
|
|
@@ -81,6 +84,52 @@ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/** Resolve the active conversation scrollport (or null when not mounted). */
|
|
88
|
+
function scrollport(): HTMLElement | null {
|
|
89
|
+
return document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One backward page that preserves the reader's scroll position. DSH's own
|
|
94
|
+
* "load older" button arms a paging anchor; a programmatic `loadOlder()` does
|
|
95
|
+
* not, so without this compensation prepended content would push the visible
|
|
96
|
+
* rows down. We restore by the exact growth of the scrollHeight.
|
|
97
|
+
*/
|
|
98
|
+
async function pagedLoadOlder(ctx: ClientContext, sessionId: SessionId): Promise<void> {
|
|
99
|
+
const binding = ctx.sessions.binding(sessionId)
|
|
100
|
+
if (binding === undefined) return
|
|
101
|
+
const port = scrollport()
|
|
102
|
+
const beforeHeight = port?.scrollHeight ?? 0
|
|
103
|
+
const beforeTop = port?.scrollTop ?? 0
|
|
104
|
+
await binding.session.loadOlder()
|
|
105
|
+
if (port === null) return
|
|
106
|
+
// Let React commit the prepend before measuring the new height.
|
|
107
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
|
|
108
|
+
const delta = port.scrollHeight - beforeHeight
|
|
109
|
+
if (delta > 0) port.scrollTop = beforeTop + delta
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Map the session to the load-all port surface. */
|
|
113
|
+
function loadAllPortsFor(ctx: ClientContext, sessionId: SessionId): Parameters<typeof loadAllOlder>[0] {
|
|
114
|
+
return {
|
|
115
|
+
snapshot: () => {
|
|
116
|
+
const binding = ctx.sessions.binding(sessionId)
|
|
117
|
+
const snap = binding?.session.getSnapshot()
|
|
118
|
+
if (snap === undefined) return undefined
|
|
119
|
+
return { openState: snap.openState, hasMore: snap.hasMore, loadingOlder: snap.loadingOlder }
|
|
120
|
+
},
|
|
121
|
+
loadOlder: () => pagedLoadOlder(ctx, sessionId),
|
|
122
|
+
isViewActive: () => document.querySelector('[data-chat-flow]') !== null,
|
|
123
|
+
now: () => Date.now(),
|
|
124
|
+
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Expand the whole session history so every question becomes a dot. */
|
|
129
|
+
function loadAllFor(ctx: ClientContext, sessionId: SessionId, options: LoadAllOptions = {}): Promise<LoadAllResult> {
|
|
130
|
+
return loadAllOlder(loadAllPortsFor(ctx, sessionId), options)
|
|
131
|
+
}
|
|
132
|
+
|
|
84
133
|
function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
85
134
|
return {
|
|
86
135
|
readQuestions: (sessionId) => {
|
|
@@ -103,6 +152,7 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
103
152
|
}
|
|
104
153
|
void jumpToQuestion(ports, key)
|
|
105
154
|
},
|
|
155
|
+
loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options),
|
|
106
156
|
}
|
|
107
157
|
}
|
|
108
158
|
|
package/src/client/locales.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
+
'strip.loadingAll': '正在加载全部历史…',
|
|
8
|
+
'strip.loadingSuffix': '…',
|
|
9
|
+
'strip.loadEarlier': '加载更早的问题',
|
|
7
10
|
'jump.inactive': '聊天视图未激活',
|
|
8
11
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
9
12
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -12,6 +15,9 @@ export const zh = {
|
|
|
12
15
|
|
|
13
16
|
export const en = {
|
|
14
17
|
'strip.empty': 'No questions in this session yet',
|
|
18
|
+
'strip.loadingAll': 'Loading full history…',
|
|
19
|
+
'strip.loadingSuffix': '…',
|
|
20
|
+
'strip.loadEarlier': 'Load earlier questions',
|
|
15
21
|
'jump.inactive': 'Chat view is not active',
|
|
16
22
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
17
23
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -58,6 +58,17 @@
|
|
|
58
58
|
background: var(--dsw-alias-brand-primary);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/* Dimmed placeholder for unloaded older history (below the count, above the
|
|
62
|
+
oldest question): click to page in more history. */
|
|
63
|
+
.moreDot {
|
|
64
|
+
background: transparent;
|
|
65
|
+
border: 1px dashed var(--dsw-alias-border-l3);
|
|
66
|
+
}
|
|
67
|
+
.moreDot:hover {
|
|
68
|
+
background: var(--dsw-alias-brand-primary);
|
|
69
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
/* Question count, rendered just above the first dot (sits in the list gap). */
|
|
62
73
|
.count {
|
|
63
74
|
flex: none;
|
|
@@ -68,6 +79,19 @@
|
|
|
68
79
|
user-select: none;
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
/* Ellipsis shown while the full history is being expanded. */
|
|
83
|
+
.countLoading {
|
|
84
|
+
margin-left: 2px;
|
|
85
|
+
font-weight: 400;
|
|
86
|
+
color: var(--dsw-alias-brand-primary);
|
|
87
|
+
animation: qnPulse 1.2s ease-in-out infinite;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes qnPulse {
|
|
91
|
+
0%, 100% { opacity: 0.4; }
|
|
92
|
+
50% { opacity: 1; }
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/* The centered group: count + dot column, centered together (the list's auto
|
|
72
96
|
margins center it when short; it scrolls as a unit when tall). */
|
|
73
97
|
.dots {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-all orchestration for the question-nav strip.
|
|
3
|
+
*
|
|
4
|
+
* DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
|
|
5
|
+
* the currently loaded window, and questions that still sit behind the "load
|
|
6
|
+
* older" button are invisible to the strip until the window is expanded
|
|
7
|
+
* backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
|
|
8
|
+
* history is materialized), so every user question becomes a dot.
|
|
9
|
+
*
|
|
10
|
+
* Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
|
|
11
|
+
* liveness, clocks) so it is unit-testable without a browser or session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface LoadAllSnapshot {
|
|
15
|
+
openState: string
|
|
16
|
+
hasMore: boolean
|
|
17
|
+
loadingOlder: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LoadAllPorts {
|
|
21
|
+
/** Read the current session snapshot; undefined when unavailable. */
|
|
22
|
+
snapshot: () => LoadAllSnapshot | undefined
|
|
23
|
+
/** Expand the window backwards by one page (may preserve scroll). */
|
|
24
|
+
loadOlder: () => Promise<void>
|
|
25
|
+
/** True while the chat view is active (a `[data-chat-flow]` is mounted). */
|
|
26
|
+
isViewActive: () => boolean
|
|
27
|
+
/** Monotonic ms clock. */
|
|
28
|
+
now: () => number
|
|
29
|
+
/** Async sleep. */
|
|
30
|
+
sleep: (ms: number) => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LoadAllOptions {
|
|
34
|
+
/** Max older pages to fetch before giving up (default 400). */
|
|
35
|
+
maxPages?: number
|
|
36
|
+
/** Total wall-clock budget for the whole expansion (default 60s). */
|
|
37
|
+
totalTimeoutMs?: number
|
|
38
|
+
/** Poll interval for open/loading transitions (default 60ms). */
|
|
39
|
+
pollMs?: number
|
|
40
|
+
/** Abort the expansion; checked every iteration. */
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LoadAllCode =
|
|
45
|
+
| 'COMPLETE'
|
|
46
|
+
| 'VIEW_INACTIVE'
|
|
47
|
+
| 'NOT_OPEN'
|
|
48
|
+
| 'BUDGET'
|
|
49
|
+
| 'TIMEOUT'
|
|
50
|
+
| 'CANCELLED'
|
|
51
|
+
|
|
52
|
+
export interface LoadAllResult {
|
|
53
|
+
ok: boolean
|
|
54
|
+
code: LoadAllCode
|
|
55
|
+
/** Number of `loadOlder` pages actually fetched. */
|
|
56
|
+
pages: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULTS = {
|
|
60
|
+
maxPages: 400,
|
|
61
|
+
totalTimeoutMs: 60_000,
|
|
62
|
+
pollMs: 60,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Expand the session window backwards until the earliest history is loaded.
|
|
67
|
+
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
68
|
+
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
69
|
+
* immediately with `COMPLETE`.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {
|
|
72
|
+
const cfg = { ...DEFAULTS, ...options }
|
|
73
|
+
const deadline = ports.now() + cfg.totalTimeoutMs
|
|
74
|
+
let pages = 0
|
|
75
|
+
|
|
76
|
+
const cancelled = (): boolean => cfg.signal?.aborted === true
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
if (cancelled()) return { ok: false, code: 'CANCELLED', pages }
|
|
80
|
+
if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }
|
|
81
|
+
const snap = ports.snapshot()
|
|
82
|
+
if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }
|
|
83
|
+
if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }
|
|
84
|
+
// Nothing older left: the whole history is in the window.
|
|
85
|
+
if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }
|
|
86
|
+
if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }
|
|
87
|
+
if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }
|
|
88
|
+
// Wait while the session is still opening or a page is already in flight
|
|
89
|
+
// (a user-initiated "load older" click shares this same gate).
|
|
90
|
+
if (snap.openState !== 'open' || snap.loadingOlder) {
|
|
91
|
+
await ports.sleep(cfg.pollMs)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
await ports.loadOlder()
|
|
95
|
+
pages += 1
|
|
96
|
+
}
|
|
97
|
+
}
|