@luziyang2026/dsh-question-nav 0.4.1 → 0.5.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 +10 -2
- package/README.zh.md +8 -2
- package/lib/client.js +303 -46
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -4
- package/lib/types/client/QuestionNavSettingsTab.d.ts +14 -0
- package/lib/types/client/QuestionNavStrip.d.ts +7 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/settings.d.ts +52 -0
- package/lib/types/core/align.d.ts +18 -0
- package/lib/types/core/turn-dots.d.ts +6 -1
- package/lib/types/index.d.ts +5 -4
- package/lib/types/settings.d.ts +24 -0
- package/package.json +7 -3
- package/src/client/QuestionNavSettingsTab.tsx +53 -0
- package/src/client/QuestionNavStrip.tsx +147 -30
- package/src/client/index.ts +28 -2
- package/src/client/locales.ts +10 -0
- package/src/client/question-nav.module.css +55 -0
- package/src/client/settings.ts +105 -0
- package/src/core/align.ts +23 -0
- package/src/core/turn-dots.ts +47 -9
- package/src/index.ts +9 -4
- package/src/settings.ts +33 -0
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# dsh-question-nav
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/AbelKeithsun/dsh-question-nav/blob/main/README.md">English</a> |
|
|
5
|
+
<a href="https://github.com/AbelKeithsun/dsh-question-nav/blob/main/README.zh.md">简体中文</a>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
3
8
|
In-session question navigator for the [DeepSeek Harness (DSH) Web GUI][dsh]: a
|
|
4
9
|
vertical column of small round dots overlaid on the **left edge** of the
|
|
5
10
|
conversation column — one dot per user question. Hover a dot to enlarge it and
|
|
@@ -14,11 +19,14 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
|
|
|
14
19
|
|
|
15
20
|
## Preview
|
|
16
21
|
|
|
17
|
-

|
|
18
23
|
|
|
19
24
|
## What it does
|
|
20
25
|
|
|
21
|
-
- **Left-edge dot minimap** (embedded, not reserving any width).
|
|
26
|
+
- **Left-edge dot minimap** (embedded, not reserving any width). The rail's
|
|
27
|
+
anchor edge is configurable — **Settings → Plugins → Question Nav → Rail
|
|
28
|
+
alignment** switches it between the left and right edge of the conversation
|
|
29
|
+
column.
|
|
22
30
|
- **Vertically centered** in the conversation column.
|
|
23
31
|
- **One dot = one turn that asked a question** (strictly aligned with the
|
|
24
32
|
Trajectory view's turn numbering), with a small count above the dot column.
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# dsh-question-nav
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/AbelKeithsun/dsh-question-nav/blob/main/README.md">English</a> |
|
|
5
|
+
<a href="https://github.com/AbelKeithsun/dsh-question-nav/blob/main/README.zh.md">简体中文</a>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
3
8
|
[DeepSeek Harness (DSH) Web GUI][dsh] 的**会话内提问导航**插件:在对话栏**左侧**
|
|
4
9
|
内嵌一列竖排的圆点小按钮 —— 每个圆点对应一个用户提问。鼠标悬停圆点会放大
|
|
5
10
|
并**立即**显示该提问的**全文**;点击圆点则把对话滚动跳到那一提问的位置。
|
|
@@ -11,11 +16,12 @@
|
|
|
11
16
|
|
|
12
17
|
## 效果展示
|
|
13
18
|
|
|
14
|
-

|
|
15
20
|
|
|
16
21
|
## 功能
|
|
17
22
|
|
|
18
|
-
-
|
|
23
|
+
- **左缘圆点迷你地图**:内嵌,**不占任何宽度**。导航条锚定边**可配置**——
|
|
24
|
+
**设置 → 插件 → 提问导航 → 导航条对齐**可在对话栏左/右缘之间切换。
|
|
19
25
|
- **垂直居中**在对话栏中。
|
|
20
26
|
- **一个圆点 = 一个含提问的 turn**(与轨迹视图的 Turn 编号严格对齐),
|
|
21
27
|
圆点列上方有圆点数量。
|
package/lib/client.js
CHANGED
|
@@ -18,13 +18,21 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
function questionKey(id) {
|
|
19
19
|
return `13:${MESSAGE_DEFINITION_KIND}${String(id)}`;
|
|
20
20
|
}
|
|
21
|
+
/** True when entries are already in non-decreasing seq order (the projection
|
|
22
|
+
* appends in event order, so this is the common case and skips the sort). */
|
|
23
|
+
function isSortedBySeq(entries) {
|
|
24
|
+
for (let i = 1; i < entries.length; i++) if (entries[i].seq < entries[i - 1].seq) return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
21
27
|
/**
|
|
22
28
|
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
23
29
|
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
24
|
-
* anchor is the turn's first question.
|
|
30
|
+
* anchor is the turn's first question. The input is expected to be sorted by
|
|
31
|
+
* seq; the defensive sort is skipped when it already is, so a long session
|
|
32
|
+
* never pays an O(n log n) sort on every content update.
|
|
25
33
|
*/
|
|
26
34
|
function groupQuestionsByTurn(entries) {
|
|
27
|
-
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
35
|
+
const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq);
|
|
28
36
|
const dots = [];
|
|
29
37
|
for (const entry of sorted) {
|
|
30
38
|
const key = questionKey(entry.id);
|
|
@@ -54,23 +62,45 @@ window.__ModuleLoader__.load({
|
|
|
54
62
|
* whose key is already folded into a dot is dropped (the projected copy
|
|
55
63
|
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
56
64
|
* anchor-seq order so the strip stays strictly chronological.
|
|
65
|
+
*
|
|
66
|
+
* Fast path: when nothing new arrives the SAME array is returned (no copy),
|
|
67
|
+
* so the caller can bail out of a re-render on identical reference.
|
|
57
68
|
*/
|
|
58
69
|
function mergeLiveQuestions(dots, live) {
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
if (live.length === 0) return dots;
|
|
71
|
+
const known = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const dot of dots) for (const key of dot.memberKeys) known.add(key);
|
|
73
|
+
const extras = [];
|
|
74
|
+
for (const question of live) {
|
|
75
|
+
if (known.has(question.key)) continue;
|
|
76
|
+
extras.push({
|
|
77
|
+
turn: null,
|
|
78
|
+
key: question.key,
|
|
79
|
+
anchorSeq: question.anchorSeq,
|
|
80
|
+
time: question.time,
|
|
81
|
+
texts: [question.text],
|
|
82
|
+
memberKeys: [question.key]
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (extras.length === 0) return dots;
|
|
86
|
+
const out = [];
|
|
87
|
+
let i = 0;
|
|
88
|
+
for (const extra of extras) {
|
|
89
|
+
while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {
|
|
90
|
+
out.push(dots[i]);
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
out.push(extra);
|
|
94
|
+
}
|
|
95
|
+
while (i < dots.length) {
|
|
96
|
+
out.push(dots[i]);
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
70
100
|
}
|
|
71
101
|
//#endregion
|
|
72
102
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
73
|
-
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}.TWf_pa_tooltipTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_tooltipLine+.TWf_pa_tooltipLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}";
|
|
103
|
+
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_railRight{left:auto}.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}.TWf_pa_tooltipTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_tooltipLine+.TWf_pa_tooltipLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
|
|
74
104
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
75
105
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
76
106
|
const tag = document.createElement("style");
|
|
@@ -87,6 +117,13 @@ window.__ModuleLoader__.load({
|
|
|
87
117
|
"empty": "TWf_pa_empty",
|
|
88
118
|
"list": "TWf_pa_list",
|
|
89
119
|
"rail": "TWf_pa_rail",
|
|
120
|
+
"railRight": "TWf_pa_railRight",
|
|
121
|
+
"segment": "TWf_pa_segment",
|
|
122
|
+
"segmentActive": "TWf_pa_segmentActive",
|
|
123
|
+
"segmented": "TWf_pa_segmented",
|
|
124
|
+
"settings": "TWf_pa_settings",
|
|
125
|
+
"settingsDesc": "TWf_pa_settingsDesc",
|
|
126
|
+
"settingsTitle": "TWf_pa_settingsTitle",
|
|
90
127
|
"tooltip": "TWf_pa_tooltip",
|
|
91
128
|
"tooltipLine": "TWf_pa_tooltipLine",
|
|
92
129
|
"tooltipTitle": "TWf_pa_tooltipTitle"
|
|
@@ -129,6 +166,19 @@ window.__ModuleLoader__.load({
|
|
|
129
166
|
function findConvRoot() {
|
|
130
167
|
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
131
168
|
}
|
|
169
|
+
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
170
|
+
* folded questions, so identical key sequences mean identical content).
|
|
171
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
172
|
+
function sameDots(a, b) {
|
|
173
|
+
if (a.length !== b.length) return false;
|
|
174
|
+
for (let i = 0; i < a.length; i++) {
|
|
175
|
+
const da = a[i];
|
|
176
|
+
const db = b[i];
|
|
177
|
+
if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false;
|
|
178
|
+
for (let j = 0; j < da.memberKeys.length; j++) if (da.memberKeys[j] !== db.memberKeys[j]) return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
132
182
|
function QuestionNavStrip(props) {
|
|
133
183
|
const current = props.useSessions((s) => s.current);
|
|
134
184
|
const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
|
|
@@ -137,8 +187,11 @@ window.__ModuleLoader__.load({
|
|
|
137
187
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
138
188
|
const [hint, setHint] = (0, react.useState)(null);
|
|
139
189
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
190
|
+
const [align, setAlign] = (0, react.useState)(() => props.align());
|
|
140
191
|
const panelRef = (0, react.useRef)(null);
|
|
141
192
|
const hintTimerRef = (0, react.useRef)(null);
|
|
193
|
+
const lastDotsRef = (0, react.useRef)([]);
|
|
194
|
+
(0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
|
|
142
195
|
const showHint = (message) => {
|
|
143
196
|
setHint(message);
|
|
144
197
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -147,22 +200,26 @@ window.__ModuleLoader__.load({
|
|
|
147
200
|
(0, react.useEffect)(() => {
|
|
148
201
|
if (!visible || current === void 0) {
|
|
149
202
|
setDots([]);
|
|
203
|
+
lastDotsRef.current = [];
|
|
150
204
|
return;
|
|
151
205
|
}
|
|
152
206
|
const sessionId = current;
|
|
153
207
|
const face = props.questionProjection(sessionId);
|
|
154
208
|
const refresh = () => {
|
|
155
|
-
const
|
|
156
|
-
|
|
209
|
+
const next = mergeLiveQuestions(groupQuestionsByTurn(projectionEntries(face)), props.readQuestions(sessionId));
|
|
210
|
+
if (sameDots(next, lastDotsRef.current)) {
|
|
211
|
+
setDots(lastDotsRef.current);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
lastDotsRef.current = next;
|
|
215
|
+
setDots(next);
|
|
157
216
|
};
|
|
158
217
|
refresh();
|
|
159
218
|
const unsubProjection = face?.subscribe(refresh) ?? (() => {});
|
|
160
219
|
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
161
|
-
const unsubList = props.subscribeList(refresh);
|
|
162
220
|
return () => {
|
|
163
221
|
unsubProjection();
|
|
164
222
|
unsubContent();
|
|
165
|
-
unsubList();
|
|
166
223
|
};
|
|
167
224
|
}, [
|
|
168
225
|
visible,
|
|
@@ -180,39 +237,84 @@ window.__ModuleLoader__.load({
|
|
|
180
237
|
(0, react.useLayoutEffect)(() => {
|
|
181
238
|
if (!visible) return;
|
|
182
239
|
let raf = 0;
|
|
183
|
-
let
|
|
240
|
+
let idle = 0;
|
|
241
|
+
let stopped = false;
|
|
242
|
+
let observer = null;
|
|
243
|
+
const observed = {
|
|
244
|
+
frame: null,
|
|
245
|
+
convRoot: null
|
|
246
|
+
};
|
|
184
247
|
const applyLayout = () => {
|
|
185
248
|
const panel = panelRef.current;
|
|
186
|
-
if (panel === null) return;
|
|
249
|
+
if (panel === null) return false;
|
|
187
250
|
const frame = panel.closest("[data-shell-overlay]")?.parentElement ?? null;
|
|
188
251
|
const convRoot = findConvRoot();
|
|
189
|
-
if (frame === null || convRoot === null) return;
|
|
252
|
+
if (frame === null || convRoot === null) return true;
|
|
253
|
+
if (observer !== null) {
|
|
254
|
+
if (observed.frame !== frame) {
|
|
255
|
+
observer.observe(frame, { box: "border-box" });
|
|
256
|
+
observed.frame = frame;
|
|
257
|
+
}
|
|
258
|
+
if (observed.convRoot !== convRoot) {
|
|
259
|
+
observer.observe(convRoot, { box: "border-box" });
|
|
260
|
+
observed.convRoot = convRoot;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
190
263
|
const frameRect = frame.getBoundingClientRect();
|
|
191
264
|
const convRect = convRoot.getBoundingClientRect();
|
|
192
|
-
if (convRect.height <= 0)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
return;
|
|
265
|
+
if (convRect.height <= 0 || convRect.width <= 0) return true;
|
|
266
|
+
const top = `${convRect.top - frameRect.top}px`;
|
|
267
|
+
const height = `${convRect.height}px`;
|
|
268
|
+
if (align === "right") {
|
|
269
|
+
const right = `${frameRect.right - convRect.right}px`;
|
|
270
|
+
if (panel.style.top === top && panel.style.height === height && panel.style.right === right && panel.style.left === "") return false;
|
|
271
|
+
panel.style.top = top;
|
|
272
|
+
panel.style.height = height;
|
|
273
|
+
panel.style.right = right;
|
|
274
|
+
panel.style.left = "";
|
|
275
|
+
return true;
|
|
198
276
|
}
|
|
199
|
-
|
|
200
|
-
panel.style.top
|
|
201
|
-
panel.style.
|
|
202
|
-
panel.style.
|
|
277
|
+
const left = `${convRect.left - frameRect.left}px`;
|
|
278
|
+
if (panel.style.top === top && panel.style.height === height && panel.style.left === left && panel.style.right === "") return false;
|
|
279
|
+
panel.style.top = top;
|
|
280
|
+
panel.style.height = height;
|
|
281
|
+
panel.style.left = left;
|
|
282
|
+
panel.style.right = "";
|
|
283
|
+
return true;
|
|
284
|
+
};
|
|
285
|
+
const loop = () => {
|
|
286
|
+
if (stopped) return;
|
|
287
|
+
raf = 0;
|
|
288
|
+
idle = applyLayout() ? 0 : idle + 1;
|
|
289
|
+
if (idle < 3) raf = requestAnimationFrame(loop);
|
|
203
290
|
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
const wake = () => {
|
|
292
|
+
if (stopped) return;
|
|
293
|
+
idle = 0;
|
|
294
|
+
if (raf === 0) raf = requestAnimationFrame(loop);
|
|
295
|
+
};
|
|
296
|
+
observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(wake);
|
|
297
|
+
if (observer !== null) {
|
|
298
|
+
const frame = panelRef.current?.closest("[data-shell-overlay]")?.parentElement ?? null;
|
|
299
|
+
const convRoot = findConvRoot();
|
|
300
|
+
if (frame !== null) {
|
|
301
|
+
observer.observe(frame, { box: "border-box" });
|
|
302
|
+
observed.frame = frame;
|
|
303
|
+
}
|
|
304
|
+
if (convRoot !== null) {
|
|
305
|
+
observer.observe(convRoot, { box: "border-box" });
|
|
306
|
+
observed.convRoot = convRoot;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
wake();
|
|
310
|
+
window.addEventListener("resize", wake);
|
|
210
311
|
return () => {
|
|
312
|
+
stopped = true;
|
|
211
313
|
if (raf !== 0) cancelAnimationFrame(raf);
|
|
212
314
|
observer?.disconnect();
|
|
213
|
-
window.removeEventListener("resize",
|
|
315
|
+
window.removeEventListener("resize", wake);
|
|
214
316
|
};
|
|
215
|
-
}, [visible]);
|
|
317
|
+
}, [visible, align]);
|
|
216
318
|
(0, react.useEffect)(() => () => {
|
|
217
319
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
218
320
|
}, []);
|
|
@@ -228,14 +330,14 @@ window.__ModuleLoader__.load({
|
|
|
228
330
|
setTooltip({
|
|
229
331
|
title: dot.turn === null ? null : `Turn ${dot.turn}`,
|
|
230
332
|
lines: dot.texts,
|
|
231
|
-
left: r.right + 10,
|
|
333
|
+
...align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 },
|
|
232
334
|
top: r.top
|
|
233
335
|
});
|
|
234
336
|
};
|
|
235
337
|
const t = props.t;
|
|
236
338
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
237
339
|
ref: panelRef,
|
|
238
|
-
className: question_nav_module_css_default.rail,
|
|
340
|
+
className: align === "right" ? `${question_nav_module_css_default.rail} ${question_nav_module_css_default.railRight}` : question_nav_module_css_default.rail,
|
|
239
341
|
"data-question-nav": "rail",
|
|
240
342
|
children: [
|
|
241
343
|
hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -266,6 +368,7 @@ window.__ModuleLoader__.load({
|
|
|
266
368
|
className: question_nav_module_css_default.tooltip,
|
|
267
369
|
style: {
|
|
268
370
|
left: tooltip.left,
|
|
371
|
+
right: tooltip.right,
|
|
269
372
|
top: tooltip.top
|
|
270
373
|
},
|
|
271
374
|
children: [tooltip.title !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -280,6 +383,134 @@ window.__ModuleLoader__.load({
|
|
|
280
383
|
});
|
|
281
384
|
}
|
|
282
385
|
//#endregion
|
|
386
|
+
//#region src/core/align.ts
|
|
387
|
+
/**
|
|
388
|
+
* Rail anchor-edge constants shared by the host schema and the browser
|
|
389
|
+
* settings scope. Pure data: no DSH imports, so the client bundle may inline
|
|
390
|
+
* this module (a Host import here would leak into the browser half).
|
|
391
|
+
*
|
|
392
|
+
* @module dsh-question-nav/align
|
|
393
|
+
*/
|
|
394
|
+
/** Supported rail anchor edges. */
|
|
395
|
+
const ALIGN_OPTIONS = ["left", "right"];
|
|
396
|
+
/** Default anchor edge when the user-settings document has no override. */
|
|
397
|
+
const DEFAULT_ALIGN = "left";
|
|
398
|
+
/** Settings namespace owned by this plugin (spelled here rather than
|
|
399
|
+
* imported: the client bundle must not depend on a Host package). */
|
|
400
|
+
const QUESTION_NAV_SETTINGS_NS = "question-nav";
|
|
401
|
+
/** Field carrying the selected anchor edge. */
|
|
402
|
+
const ALIGN_FIELD = "align";
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/client/QuestionNavSettingsTab.tsx
|
|
405
|
+
/**
|
|
406
|
+
* The plugin's settings page inside the shell's Plugins section
|
|
407
|
+
* (`settings.plugins.tab`): a segmented control choosing which edge of the
|
|
408
|
+
* conversation column the dot rail anchors to. The choice is written to the
|
|
409
|
+
* `question-nav` settings namespace (registered by the host half); the strip
|
|
410
|
+
* re-anchors live when the snapshot changes.
|
|
411
|
+
*
|
|
412
|
+
* @module dsh-question-nav/client/settings-tab
|
|
413
|
+
*/
|
|
414
|
+
/** Re-render on settings snapshot changes (the register inject face is static). */
|
|
415
|
+
function useAlignTick(subscribe) {
|
|
416
|
+
const [, bump] = (0, react.useState)(0);
|
|
417
|
+
(0, react.useEffect)(() => subscribe(() => bump((n) => n + 1)), [subscribe]);
|
|
418
|
+
}
|
|
419
|
+
function QuestionNavSettingsTab(props) {
|
|
420
|
+
useAlignTick(props.subscribeAlign);
|
|
421
|
+
const align = props.align();
|
|
422
|
+
const t = props.t;
|
|
423
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
424
|
+
className: question_nav_module_css_default.settings,
|
|
425
|
+
children: [
|
|
426
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
427
|
+
className: question_nav_module_css_default.settingsTitle,
|
|
428
|
+
children: t("settings.align.title")
|
|
429
|
+
}),
|
|
430
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
431
|
+
className: question_nav_module_css_default.settingsDesc,
|
|
432
|
+
children: t("settings.align.desc")
|
|
433
|
+
}),
|
|
434
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
435
|
+
className: question_nav_module_css_default.segmented,
|
|
436
|
+
role: "radiogroup",
|
|
437
|
+
"aria-label": t("settings.align.title"),
|
|
438
|
+
children: ALIGN_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
439
|
+
type: "button",
|
|
440
|
+
role: "radio",
|
|
441
|
+
"aria-checked": align === option,
|
|
442
|
+
className: align === option ? `${question_nav_module_css_default.segment} ${question_nav_module_css_default.segmentActive}` : question_nav_module_css_default.segment,
|
|
443
|
+
onClick: () => props.setAlign(option),
|
|
444
|
+
children: t(option === "left" ? "settings.align.left" : "settings.align.right")
|
|
445
|
+
}, option))
|
|
446
|
+
})
|
|
447
|
+
]
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/client/settings.ts
|
|
452
|
+
/** Narrow a raw section to the anchor field. */
|
|
453
|
+
function isAlignPreference(value) {
|
|
454
|
+
return ALIGN_OPTIONS.some((option) => option === value);
|
|
455
|
+
}
|
|
456
|
+
/** Reactive handle over the plugin's durable settings section. */
|
|
457
|
+
var QuestionNavSettingsController = class {
|
|
458
|
+
scope;
|
|
459
|
+
listeners = /* @__PURE__ */ new Set();
|
|
460
|
+
unsubscribe = () => {};
|
|
461
|
+
state = {
|
|
462
|
+
align: DEFAULT_ALIGN,
|
|
463
|
+
overridden: false
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* Bind the namespace scope once the settings surface is present. Called
|
|
467
|
+
* from a fiber that injects `settingsScope`, so the scope subscription
|
|
468
|
+
* lives on that fiber and is released with it. A no-op after the first
|
|
469
|
+
* bind.
|
|
470
|
+
* @param binder - the settings scope service.
|
|
471
|
+
*/
|
|
472
|
+
attach(binder) {
|
|
473
|
+
if (this.scope !== void 0) return;
|
|
474
|
+
this.scope = binder.bind({ namespace: QUESTION_NAV_SETTINGS_NS });
|
|
475
|
+
this.state = this.derive(this.scope.getSnapshot());
|
|
476
|
+
this.unsubscribe = this.scope.subscribe(() => {
|
|
477
|
+
if (this.scope === void 0) return;
|
|
478
|
+
const next = this.derive(this.scope.getSnapshot());
|
|
479
|
+
if (next.align === this.state.align && next.overridden === this.state.overridden) return;
|
|
480
|
+
this.state = next;
|
|
481
|
+
for (const listener of this.listeners) listener();
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
derive(snapshot) {
|
|
485
|
+
const user = snapshot.user;
|
|
486
|
+
return {
|
|
487
|
+
align: snapshot.status === "ready" && isAlignPreference(snapshot.value?.align) ? snapshot.value.align : DEFAULT_ALIGN,
|
|
488
|
+
overridden: user !== void 0 && user.align !== void 0
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/** Release the scope subscription (bound on the settings fiber's lifecycle). */
|
|
492
|
+
dispose() {
|
|
493
|
+
this.unsubscribe();
|
|
494
|
+
this.listeners.clear();
|
|
495
|
+
}
|
|
496
|
+
/** @returns the current state (stable reference until the next change). */
|
|
497
|
+
getSnapshot() {
|
|
498
|
+
return this.state;
|
|
499
|
+
}
|
|
500
|
+
/** Observe state replacements; returns the disposer. */
|
|
501
|
+
subscribe(listener) {
|
|
502
|
+
this.listeners.add(listener);
|
|
503
|
+
return () => {
|
|
504
|
+
this.listeners.delete(listener);
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/** Route the user's anchor-edge choice to the Host document. */
|
|
508
|
+
setAlign(align) {
|
|
509
|
+
if (this.scope === void 0) return;
|
|
510
|
+
this.scope.set(ALIGN_FIELD, align);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
//#endregion
|
|
283
514
|
//#region src/client/locales.ts
|
|
284
515
|
/**
|
|
285
516
|
* Locale dictionaries for the question-nav surface (zh/en). Registered under
|
|
@@ -290,14 +521,24 @@ window.__ModuleLoader__.load({
|
|
|
290
521
|
"jump.inactive": "聊天视图未激活",
|
|
291
522
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
292
523
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
293
|
-
"jump.timeout": "加载历史超时,可重试"
|
|
524
|
+
"jump.timeout": "加载历史超时,可重试",
|
|
525
|
+
"settings.tab": "提问导航",
|
|
526
|
+
"settings.align.title": "导航条对齐",
|
|
527
|
+
"settings.align.desc": "选择圆点导航条锚定在对话栏的哪一侧。",
|
|
528
|
+
"settings.align.left": "左侧",
|
|
529
|
+
"settings.align.right": "右侧"
|
|
294
530
|
};
|
|
295
531
|
const en = {
|
|
296
532
|
"strip.empty": "No questions in this session yet",
|
|
297
533
|
"jump.inactive": "Chat view is not active",
|
|
298
534
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
299
535
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
300
|
-
"jump.timeout": "Timed out loading history; retry"
|
|
536
|
+
"jump.timeout": "Timed out loading history; retry",
|
|
537
|
+
"settings.tab": "Question Nav",
|
|
538
|
+
"settings.align.title": "Rail alignment",
|
|
539
|
+
"settings.align.desc": "Choose which edge of the conversation column the dot rail anchors to.",
|
|
540
|
+
"settings.align.left": "Left",
|
|
541
|
+
"settings.align.right": "Right"
|
|
301
542
|
};
|
|
302
543
|
//#endregion
|
|
303
544
|
//#region src/core/nodes.ts
|
|
@@ -505,7 +746,7 @@ window.__ModuleLoader__.load({
|
|
|
505
746
|
subscribe: (listener) => face.subscribe(listener)
|
|
506
747
|
};
|
|
507
748
|
}
|
|
508
|
-
function createInject(ctx) {
|
|
749
|
+
function createInject(ctx, settings) {
|
|
509
750
|
return {
|
|
510
751
|
readQuestions: (sessionId) => {
|
|
511
752
|
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
@@ -525,7 +766,10 @@ window.__ModuleLoader__.load({
|
|
|
525
766
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
526
767
|
};
|
|
527
768
|
jumpToQuestion(ports, key);
|
|
528
|
-
}
|
|
769
|
+
},
|
|
770
|
+
align: () => settings.getSnapshot().align,
|
|
771
|
+
subscribeAlign: (cb) => settings.subscribe(cb),
|
|
772
|
+
setAlign: (align) => settings.setAlign(align)
|
|
529
773
|
};
|
|
530
774
|
}
|
|
531
775
|
/**
|
|
@@ -539,7 +783,9 @@ window.__ModuleLoader__.load({
|
|
|
539
783
|
zh,
|
|
540
784
|
en
|
|
541
785
|
}), "question-nav: dictionaries");
|
|
542
|
-
const
|
|
786
|
+
const t = ctx.locale.bind(NS);
|
|
787
|
+
const settings = new QuestionNavSettingsController();
|
|
788
|
+
const injected = createInject(ctx, settings);
|
|
543
789
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
544
790
|
name: "shell.overlay",
|
|
545
791
|
id: "question-nav",
|
|
@@ -547,6 +793,17 @@ window.__ModuleLoader__.load({
|
|
|
547
793
|
locale: NS,
|
|
548
794
|
inject: () => injected
|
|
549
795
|
}, QuestionNavStrip));
|
|
796
|
+
ctx.inject(["settingsScope"], (scopeCtx) => {
|
|
797
|
+
settings.attach(scopeCtx.get("settingsScope"));
|
|
798
|
+
ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
|
|
799
|
+
name: "settings.plugins.tab",
|
|
800
|
+
id: "question-nav",
|
|
801
|
+
order: 100,
|
|
802
|
+
label: () => t("settings.tab"),
|
|
803
|
+
locale: NS,
|
|
804
|
+
inject: () => injected
|
|
805
|
+
}, QuestionNavSettingsTab));
|
|
806
|
+
});
|
|
550
807
|
}
|
|
551
808
|
//#endregion
|
|
552
809
|
exports.apply = apply;
|