@luziyang2026/dsh-question-nav 0.4.2 → 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 +184 -10
- 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/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 +44 -6
- 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/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
|
@@ -100,7 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
100
100
|
}
|
|
101
101
|
//#endregion
|
|
102
102
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
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_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)}";
|
|
104
104
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
105
105
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
106
106
|
const tag = document.createElement("style");
|
|
@@ -117,6 +117,13 @@ window.__ModuleLoader__.load({
|
|
|
117
117
|
"empty": "TWf_pa_empty",
|
|
118
118
|
"list": "TWf_pa_list",
|
|
119
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",
|
|
120
127
|
"tooltip": "TWf_pa_tooltip",
|
|
121
128
|
"tooltipLine": "TWf_pa_tooltipLine",
|
|
122
129
|
"tooltipTitle": "TWf_pa_tooltipTitle"
|
|
@@ -180,9 +187,11 @@ window.__ModuleLoader__.load({
|
|
|
180
187
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
181
188
|
const [hint, setHint] = (0, react.useState)(null);
|
|
182
189
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
190
|
+
const [align, setAlign] = (0, react.useState)(() => props.align());
|
|
183
191
|
const panelRef = (0, react.useRef)(null);
|
|
184
192
|
const hintTimerRef = (0, react.useRef)(null);
|
|
185
193
|
const lastDotsRef = (0, react.useRef)([]);
|
|
194
|
+
(0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
|
|
186
195
|
const showHint = (message) => {
|
|
187
196
|
setHint(message);
|
|
188
197
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -256,11 +265,21 @@ window.__ModuleLoader__.load({
|
|
|
256
265
|
if (convRect.height <= 0 || convRect.width <= 0) return true;
|
|
257
266
|
const top = `${convRect.top - frameRect.top}px`;
|
|
258
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;
|
|
276
|
+
}
|
|
259
277
|
const left = `${convRect.left - frameRect.left}px`;
|
|
260
|
-
if (panel.style.top === top && panel.style.height === height && panel.style.left === left) return false;
|
|
278
|
+
if (panel.style.top === top && panel.style.height === height && panel.style.left === left && panel.style.right === "") return false;
|
|
261
279
|
panel.style.top = top;
|
|
262
280
|
panel.style.height = height;
|
|
263
281
|
panel.style.left = left;
|
|
282
|
+
panel.style.right = "";
|
|
264
283
|
return true;
|
|
265
284
|
};
|
|
266
285
|
const loop = () => {
|
|
@@ -295,7 +314,7 @@ window.__ModuleLoader__.load({
|
|
|
295
314
|
observer?.disconnect();
|
|
296
315
|
window.removeEventListener("resize", wake);
|
|
297
316
|
};
|
|
298
|
-
}, [visible]);
|
|
317
|
+
}, [visible, align]);
|
|
299
318
|
(0, react.useEffect)(() => () => {
|
|
300
319
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
301
320
|
}, []);
|
|
@@ -311,14 +330,14 @@ window.__ModuleLoader__.load({
|
|
|
311
330
|
setTooltip({
|
|
312
331
|
title: dot.turn === null ? null : `Turn ${dot.turn}`,
|
|
313
332
|
lines: dot.texts,
|
|
314
|
-
left: r.right + 10,
|
|
333
|
+
...align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 },
|
|
315
334
|
top: r.top
|
|
316
335
|
});
|
|
317
336
|
};
|
|
318
337
|
const t = props.t;
|
|
319
338
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
320
339
|
ref: panelRef,
|
|
321
|
-
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,
|
|
322
341
|
"data-question-nav": "rail",
|
|
323
342
|
children: [
|
|
324
343
|
hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -349,6 +368,7 @@ window.__ModuleLoader__.load({
|
|
|
349
368
|
className: question_nav_module_css_default.tooltip,
|
|
350
369
|
style: {
|
|
351
370
|
left: tooltip.left,
|
|
371
|
+
right: tooltip.right,
|
|
352
372
|
top: tooltip.top
|
|
353
373
|
},
|
|
354
374
|
children: [tooltip.title !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -363,6 +383,134 @@ window.__ModuleLoader__.load({
|
|
|
363
383
|
});
|
|
364
384
|
}
|
|
365
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
|
|
366
514
|
//#region src/client/locales.ts
|
|
367
515
|
/**
|
|
368
516
|
* Locale dictionaries for the question-nav surface (zh/en). Registered under
|
|
@@ -373,14 +521,24 @@ window.__ModuleLoader__.load({
|
|
|
373
521
|
"jump.inactive": "聊天视图未激活",
|
|
374
522
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
375
523
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
376
|
-
"jump.timeout": "加载历史超时,可重试"
|
|
524
|
+
"jump.timeout": "加载历史超时,可重试",
|
|
525
|
+
"settings.tab": "提问导航",
|
|
526
|
+
"settings.align.title": "导航条对齐",
|
|
527
|
+
"settings.align.desc": "选择圆点导航条锚定在对话栏的哪一侧。",
|
|
528
|
+
"settings.align.left": "左侧",
|
|
529
|
+
"settings.align.right": "右侧"
|
|
377
530
|
};
|
|
378
531
|
const en = {
|
|
379
532
|
"strip.empty": "No questions in this session yet",
|
|
380
533
|
"jump.inactive": "Chat view is not active",
|
|
381
534
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
382
535
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
383
|
-
"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"
|
|
384
542
|
};
|
|
385
543
|
//#endregion
|
|
386
544
|
//#region src/core/nodes.ts
|
|
@@ -588,7 +746,7 @@ window.__ModuleLoader__.load({
|
|
|
588
746
|
subscribe: (listener) => face.subscribe(listener)
|
|
589
747
|
};
|
|
590
748
|
}
|
|
591
|
-
function createInject(ctx) {
|
|
749
|
+
function createInject(ctx, settings) {
|
|
592
750
|
return {
|
|
593
751
|
readQuestions: (sessionId) => {
|
|
594
752
|
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
@@ -608,7 +766,10 @@ window.__ModuleLoader__.load({
|
|
|
608
766
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
609
767
|
};
|
|
610
768
|
jumpToQuestion(ports, key);
|
|
611
|
-
}
|
|
769
|
+
},
|
|
770
|
+
align: () => settings.getSnapshot().align,
|
|
771
|
+
subscribeAlign: (cb) => settings.subscribe(cb),
|
|
772
|
+
setAlign: (align) => settings.setAlign(align)
|
|
612
773
|
};
|
|
613
774
|
}
|
|
614
775
|
/**
|
|
@@ -622,7 +783,9 @@ window.__ModuleLoader__.load({
|
|
|
622
783
|
zh,
|
|
623
784
|
en
|
|
624
785
|
}), "question-nav: dictionaries");
|
|
625
|
-
const
|
|
786
|
+
const t = ctx.locale.bind(NS);
|
|
787
|
+
const settings = new QuestionNavSettingsController();
|
|
788
|
+
const injected = createInject(ctx, settings);
|
|
626
789
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
627
790
|
name: "shell.overlay",
|
|
628
791
|
id: "question-nav",
|
|
@@ -630,6 +793,17 @@ window.__ModuleLoader__.load({
|
|
|
630
793
|
locale: NS,
|
|
631
794
|
inject: () => injected
|
|
632
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
|
+
});
|
|
633
807
|
}
|
|
634
808
|
//#endregion
|
|
635
809
|
exports.apply = apply;
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal"],"sources":["../src/core/turn-dots.ts","../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\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 turn that\n * claimed at least one user question — strictly aligned with the Trajectory\n * view's turn numbering (turns without a question produce no dot). Hover\n * enlarges a dot and shows an instant tooltip (portal-rendered, no native\n * delay) with the turn label and the turn's question text(s); clicking jumps\n * the chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), 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 { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\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's currently 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 /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\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 /** Turn label line (e.g. \"Turn 32\"); null for ungrouped live questions. */\n title: string | null\n /** Question text lines (one per question folded into the dot). */\n lines: readonly string[]\n left: number\n top: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\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 [dots, setDots] = useState<TurnDot[]>([])\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 // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\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 // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\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 //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height && panel.style.left === left) {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\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 = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openTooltip = (dot: TurnDot, target: HTMLElement): void => {\n const r = target.getBoundingClientRect()\n setTooltip({\n title: dot.turn === null ? null : `Turn ${dot.turn}`,\n lines: dot.texts,\n left: r.right + 10,\n top: r.top,\n })\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 {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot) => (\n <button\n key={dot.key}\n className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(dot)}\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.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}\n {tooltip.lines.map((line, index) => (\n <div key={index} className={styles.tooltipLine}>{line}</div>\n ))}\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-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, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\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 ObservableFace, 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\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\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 questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\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":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal","useState","styles"],"sources":["../src/core/turn-dots.ts","../src/client/QuestionNavStrip.tsx","../src/core/align.ts","../src/client/QuestionNavSettingsTab.tsx","../src/client/settings.ts","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\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 turn that\n * claimed at least one user question — strictly aligned with the Trajectory\n * view's turn numbering (turns without a question produce no dot). Hover\n * enlarges a dot and shows an instant tooltip (portal-rendered, no native\n * delay) with the turn label and the turn's question text(s); clicking jumps\n * the chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), 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 { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { AlignPreference } from '../core/align.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\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's currently 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 /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */\n align: () => AlignPreference\n /** Observe anchor-edge changes; returns an unsubscribe. */\n subscribeAlign: (cb: () => void) => () => void\n /** Persist a new anchor edge. */\n setAlign: (align: AlignPreference) => 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 /** Turn label line (e.g. \"Turn 32\"); null for ungrouped live questions. */\n title: string | null\n /** Question text lines (one per question folded into the dot). */\n lines: readonly string[]\n /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */\n left?: number\n right?: number\n top: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\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 [dots, setDots] = useState<TurnDot[]>([])\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 [align, setAlign] = useState<AlignPreference>(() => props.align())\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n\n // Follow the rail anchor edge from the settings scope (a change re-anchors\n // the rail through the layout effect below).\n useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])\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 // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\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 //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n // Anchor to the configured edge: keep the other edge's inline style\n // cleared so the CSS class (left: auto on .railRight) owns it.\n if (align === 'right') {\n const right = `${frameRect.right - convRect.right}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.right === right && panel.style.left === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.right = right\n panel.style.left = ''\n return true\n }\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.left === left && panel.style.right === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n panel.style.right = ''\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible, align])\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 = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openTooltip = (dot: TurnDot, target: HTMLElement): void => {\n const r = target.getBoundingClientRect()\n setTooltip({\n title: dot.turn === null ? null : `Turn ${dot.turn}`,\n lines: dot.texts,\n // The tooltip opens away from the rail: to the right of the dot on a\n // left-anchored rail, to the left on a right-anchored one.\n ...(align === 'right'\n ? { right: window.innerWidth - r.left + 10 }\n : { left: r.right + 10 }),\n top: r.top,\n })\n }\n\n const t = props.t\n\n return (\n <div\n ref={panelRef}\n className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}\n data-question-nav=\"rail\"\n >\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot) => (\n <button\n key={dot.key}\n className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(dot)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, right: tooltip.right, top: tooltip.top }}>\n {tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}\n {tooltip.lines.map((line, index) => (\n <div key={index} className={styles.tooltipLine}>{line}</div>\n ))}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Rail anchor-edge constants shared by the host schema and the browser\n * settings scope. Pure data: no DSH imports, so the client bundle may inline\n * this module (a Host import here would leak into the browser half).\n *\n * @module dsh-question-nav/align\n */\n\n/** Supported rail anchor edges. */\nexport const ALIGN_OPTIONS = ['left', 'right'] as const\n\n/** Rail anchor edge preference. */\nexport type AlignPreference = typeof ALIGN_OPTIONS[number]\n\n/** Default anchor edge when the user-settings document has no override. */\nexport const DEFAULT_ALIGN: AlignPreference = 'left'\n\n/** Settings namespace owned by this plugin (spelled here rather than\n * imported: the client bundle must not depend on a Host package). */\nexport const QUESTION_NAV_SETTINGS_NS = 'question-nav'\n\n/** Field carrying the selected anchor edge. */\nexport const ALIGN_FIELD = 'align'\n","/**\n * The plugin's settings page inside the shell's Plugins section\n * (`settings.plugins.tab`): a segmented control choosing which edge of the\n * conversation column the dot rail anchors to. The choice is written to the\n * `question-nav` settings namespace (registered by the host half); the strip\n * re-anchors live when the snapshot changes.\n *\n * @module dsh-question-nav/client/settings-tab\n */\n\nimport { useEffect, useState } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { ALIGN_OPTIONS } from '../core/align.ts'\nimport type { QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\ntype ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\n/** Re-render on settings snapshot changes (the register inject face is static). */\nfunction useAlignTick(subscribe: QuestionNavInjected['subscribeAlign']): void {\n const [, bump] = useState(0)\n useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])\n}\n\nexport function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {\n useAlignTick(props.subscribeAlign)\n const align = props.align()\n const t = props.t\n\n return (\n <div className={styles.settings}>\n <p className={styles.settingsTitle}>{t('settings.align.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.align.title')}>\n {ALIGN_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={align === option}\n className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setAlign(option)}\n >\n {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}\n </button>\n ))}\n </div>\n </div>\n )\n}\n","/**\n * Browser mirror of the `question-nav` settings namespace: reads the rail\n * anchor edge from the settings scope (`ctx.settingsScope.bind`) and routes\n * the user's choice back through `scope.set`. The namespace itself is\n * registered by the host half (src/settings.ts).\n *\n * The settings surface is optional and may apply after this plugin, so the\n * controller starts unbound and degrades to the default alignment until\n * {@link attach} binds the scope (called from a fiber that injects\n * `settingsScope`). The plugin keeps working everywhere it already did.\n *\n * @module dsh-question-nav/client/settings\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'\nimport {\n ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,\n type AlignPreference,\n} from '../core/align.ts'\n\n/** Narrow a raw section to the anchor field. */\nfunction isAlignPreference(value: unknown): value is AlignPreference {\n return ALIGN_OPTIONS.some((option) => option === value)\n}\n\n/** The minimal face of the settings scope service this controller needs.\n * Kept structural (bind only) so the controller stays decoupled from the\n * full service and is unit-testable with a stub binder. */\nexport interface SettingsScopeBinderLike {\n bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>\n}\n\n/** Wire section this plugin owns (the Host schema's `align` field). */\ninterface QuestionNavSection {\n align?: unknown\n}\n\n/** Snapshot consumed by the strip and the settings row. */\nexport interface QuestionNavSettingsState {\n /** Last accepted anchor edge (default while the scope is absent/loading). */\n align: AlignPreference\n /** Whether the user layer overrides the composition default. */\n overridden: boolean\n}\n\n/** Reactive handle over the plugin's durable settings section. */\nexport class QuestionNavSettingsController {\n private scope: SettingsScope<QuestionNavSection> | undefined\n private readonly listeners = new Set<() => void>()\n private unsubscribe: () => void = () => {}\n private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }\n\n /**\n * Bind the namespace scope once the settings surface is present. Called\n * from a fiber that injects `settingsScope`, so the scope subscription\n * lives on that fiber and is released with it. A no-op after the first\n * bind.\n * @param binder - the settings scope service.\n */\n attach(binder: SettingsScopeBinderLike): void {\n if (this.scope !== undefined) return\n this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })\n this.state = this.derive(this.scope.getSnapshot())\n this.unsubscribe = this.scope.subscribe(() => {\n if (this.scope === undefined) return\n const next = this.derive(this.scope.getSnapshot())\n if (next.align === this.state.align && next.overridden === this.state.overridden) return\n this.state = next\n for (const listener of this.listeners) listener()\n })\n }\n\n private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {\n const user = snapshot.user as { align?: unknown } | undefined\n return {\n align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)\n ? snapshot.value.align\n : DEFAULT_ALIGN,\n overridden: user !== undefined && user.align !== undefined,\n }\n }\n\n /** Release the scope subscription (bound on the settings fiber's lifecycle). */\n dispose(): void {\n this.unsubscribe()\n this.listeners.clear()\n }\n\n /** @returns the current state (stable reference until the next change). */\n getSnapshot(): QuestionNavSettingsState {\n return this.state\n }\n\n /** Observe state replacements; returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n /** Route the user's anchor-edge choice to the Host document. */\n setAlign(align: AlignPreference): void {\n if (this.scope === undefined) return\n void this.scope.set(ALIGN_FIELD, align)\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 'settings.tab': '提问导航',\n 'settings.align.title': '导航条对齐',\n 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',\n 'settings.align.left': '左侧',\n 'settings.align.right': '右侧',\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 'settings.tab': 'Question Nav',\n 'settings.align.title': 'Rail alignment',\n 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',\n 'settings.align.left': 'Left',\n 'settings.align.right': 'Right',\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-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, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\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'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')\n// and the ctx.settingsScope Context merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'\nimport { QuestionNavSettingsController } from './settings.ts'\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\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext, settings: QuestionNavSettingsController): 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 questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\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 align: () => settings.getSnapshot().align,\n subscribeAlign: (cb) => settings.subscribe(cb),\n setAlign: (align) => settings.setAlign(align),\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 const t = ctx.locale.bind(NS)\n\n const settings = new QuestionNavSettingsController()\n const injected = createInject(ctx, settings)\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 // The settings surface is optional and may apply after this plugin, so the\n // namespace binds and the settings page mounts in a fiber that waits for\n // it. Without it the strip simply keeps the default left alignment.\n ctx.inject(['settingsScope'], (scopeCtx) => {\n settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)\n ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({\n name: 'settings.plugins.tab',\n id: 'question-nav',\n order: 100,\n label: () => t('settings.tab'),\n locale: NS,\n inject: () => injected,\n }, QuestionNavSettingsTab))\n })\n}\n"],"mappings":"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"}
|