@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 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
- ![Left-edge dot minimap with a hover tooltip showing the turn label and full question text](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
22
+ ![Full DSH Web GUI screenshot with the question-nav dot rail embedded on the left edge of the conversation column](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
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
- ![左缘圆点迷你地图:竖排圆点,每个圆点对应一个用户提问,悬停显示 Turn 编号与全文](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
19
+ ![DSH Web GUI 完整截图:对话栏左侧内嵌提问导航圆点列,每个圆点对应一个用户提问](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
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
- const known = new Set(dots.flatMap((dot) => dot.memberKeys));
60
- const extras = live.filter((question) => !known.has(question.key)).map((question) => ({
61
- turn: null,
62
- key: question.key,
63
- anchorSeq: question.anchorSeq,
64
- time: question.time,
65
- texts: [question.text],
66
- memberKeys: [question.key]
67
- }));
68
- if (extras.length === 0) return [...dots];
69
- return [...dots, ...extras].sort((a, b) => a.anchorSeq - b.anchorSeq);
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 grouped = groupQuestionsByTurn(projectionEntries(face));
156
- setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
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 retries = 0;
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
- if (retries < 20) {
194
- retries += 1;
195
- raf = requestAnimationFrame(applyLayout);
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
- retries = 0;
200
- panel.style.top = `${convRect.top - frameRect.top}px`;
201
- panel.style.height = `${convRect.height}px`;
202
- panel.style.left = `${convRect.left - frameRect.left}px`;
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
- applyLayout();
205
- raf = requestAnimationFrame(applyLayout);
206
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(applyLayout);
207
- const convRoot = findConvRoot();
208
- observer?.observe(convRoot ?? document.body, { box: "border-box" });
209
- window.addEventListener("resize", applyLayout);
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", applyLayout);
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 injected = createInject(ctx);
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;