@luziyang2026/dsh-question-nav 0.2.0 → 0.4.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 -7
- package/README.zh.md +7 -4
- package/lib/client.js +142 -225
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +11 -5
- package/lib/types/client/index.d.ts +10 -4
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/question-entry.d.ts +17 -0
- package/lib/types/core/turn-dots.d.ts +48 -0
- package/lib/types/index.d.ts +17 -6
- package/lib/types/projection.d.ts +38 -0
- package/package.json +8 -4
- package/src/client/QuestionNavStrip.tsx +83 -103
- package/src/client/index.ts +20 -47
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/question-entry.ts +17 -0
- package/src/core/turn-dots.ts +98 -0
- package/src/index.ts +23 -6
- package/src/projection.ts +98 -0
- package/src/core/load-all.ts +0 -97
package/README.md
CHANGED
|
@@ -17,15 +17,18 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm]).
|
|
|
17
17
|
- **Left-edge dot minimap** (embedded, not reserving any width).
|
|
18
18
|
- **Vertically centered** in the conversation column.
|
|
19
19
|
- **One dot = one user question**, with a small count above the dot column.
|
|
20
|
-
- **Full history
|
|
21
|
-
|
|
22
|
-
collapsed behind
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
- **Full history, no render-window expansion**: the index is built by paging
|
|
21
|
+
the raw `session.history` RPC (read-only), so every question in the session —
|
|
22
|
+
including ones still collapsed behind DSH's "load older" button — appears as
|
|
23
|
+
a dot without expanding the conversation's paged window (memory economy
|
|
24
|
+
preserved). While the index is building the count shows a "…"; if the safety
|
|
25
|
+
budget is exhausted a dimmed dashed dot above the oldest question offers
|
|
26
|
+
"load earlier" on click.
|
|
25
27
|
- **Hover**: the dot enlarges and an instant tooltip (portal-rendered, no
|
|
26
28
|
native-title delay) shows the question's **full text**.
|
|
27
|
-
- **Click**: jumps to that question
|
|
28
|
-
|
|
29
|
+
- **Click**: jumps to that question. Only then does the jump loop page the
|
|
30
|
+
window (`loadOlder()`) to bring that specific page into view — never the
|
|
31
|
+
whole history up front.
|
|
29
32
|
- Empty/left areas of the rail pass pointer events through to the conversation
|
|
30
33
|
(it never blocks the chat).
|
|
31
34
|
|
package/README.zh.md
CHANGED
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
- **左缘圆点迷你地图**:内嵌,**不占任何宽度**。
|
|
15
15
|
- **垂直居中**在对话栏中。
|
|
16
16
|
- **一个圆点 = 一个用户提问**,圆点列上方有提问数量。
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
- **完整历史、不展开渲染窗口**:索引通过分页读取原始 `session.history`
|
|
18
|
+
RPC(只读)构建,所以会话里的每一个提问——包括仍被 DSH"加载更早"按钮
|
|
19
|
+
折叠隐藏的老提问——都会变成圆点,**不会**展开对话的分页窗口(保留 DSH
|
|
20
|
+
的内存经济性)。构建索引期间数量后显示 "…";若超过安全预算,最老提问
|
|
21
|
+
上方会出现一个虚线圆点,点击继续加载。
|
|
20
22
|
- **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示**全文**。
|
|
21
|
-
-
|
|
23
|
+
- **点击**:跳转到该提问;只有这时跳转循环才 `loadOlder()` 扩窗,把**那一页**
|
|
24
|
+
带进视图——绝不会一次性展开整个历史。
|
|
22
25
|
- 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
|
|
23
26
|
|
|
24
27
|
## 环境要求
|
package/lib/client.js
CHANGED
|
@@ -7,8 +7,70 @@ window.__ModuleLoader__.load({
|
|
|
7
7
|
let react = require("react");
|
|
8
8
|
let react_dom = require("react-dom");
|
|
9
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
//#region src/core/turn-dots.ts
|
|
11
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
12
|
+
const MESSAGE_DEFINITION_KIND = "input-message";
|
|
13
|
+
/**
|
|
14
|
+
* The engine-owned stable chat key for a user question — mirrors
|
|
15
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
16
|
+
* (verified against that formula in the unit test).
|
|
17
|
+
*/
|
|
18
|
+
function questionKey(id) {
|
|
19
|
+
return `13:${MESSAGE_DEFINITION_KIND}${String(id)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
23
|
+
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
24
|
+
* anchor is the turn's first question.
|
|
25
|
+
*/
|
|
26
|
+
function groupQuestionsByTurn(entries) {
|
|
27
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
28
|
+
const dots = [];
|
|
29
|
+
for (const entry of sorted) {
|
|
30
|
+
const key = questionKey(entry.id);
|
|
31
|
+
const last = dots.at(-1);
|
|
32
|
+
if (last !== void 0 && last.turn === entry.turn) {
|
|
33
|
+
dots[dots.length - 1] = {
|
|
34
|
+
...last,
|
|
35
|
+
texts: [...last.texts, entry.text],
|
|
36
|
+
memberKeys: [...last.memberKeys, key]
|
|
37
|
+
};
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
dots.push({
|
|
41
|
+
turn: entry.turn,
|
|
42
|
+
key,
|
|
43
|
+
anchorSeq: entry.seq,
|
|
44
|
+
time: entry.time,
|
|
45
|
+
texts: [entry.text],
|
|
46
|
+
memberKeys: [key]
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return dots;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Merge live-window questions the projection has not recorded yet (the brief
|
|
53
|
+
* window before the session/projection push frame lands). A live question
|
|
54
|
+
* whose key is already folded into a dot is dropped (the projected copy
|
|
55
|
+
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
56
|
+
* anchor-seq order so the strip stays strictly chronological.
|
|
57
|
+
*/
|
|
58
|
+
function mergeLiveQuestions(dots, live) {
|
|
59
|
+
const known = new Set(dots.flatMap((dot) => dot.memberKeys));
|
|
60
|
+
const extras = live.filter((question) => !known.has(question.key)).map((question) => ({
|
|
61
|
+
turn: null,
|
|
62
|
+
key: question.key,
|
|
63
|
+
anchorSeq: question.anchorSeq,
|
|
64
|
+
time: question.time,
|
|
65
|
+
texts: [question.text],
|
|
66
|
+
memberKeys: [question.key]
|
|
67
|
+
}));
|
|
68
|
+
if (extras.length === 0) return [...dots];
|
|
69
|
+
return [...dots, ...extras].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
10
72
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
11
|
-
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.
|
|
73
|
+
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}.TWf_pa_tooltipTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_tooltipLine+.TWf_pa_tooltipLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}";
|
|
12
74
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
13
75
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
14
76
|
const tag = document.createElement("style");
|
|
@@ -20,35 +82,37 @@ window.__ModuleLoader__.load({
|
|
|
20
82
|
var question_nav_module_css_default = {
|
|
21
83
|
"active": "TWf_pa_active",
|
|
22
84
|
"count": "TWf_pa_count",
|
|
23
|
-
"countLoading": "TWf_pa_countLoading",
|
|
24
85
|
"dot": "TWf_pa_dot",
|
|
25
86
|
"dots": "TWf_pa_dots",
|
|
26
87
|
"empty": "TWf_pa_empty",
|
|
27
88
|
"list": "TWf_pa_list",
|
|
28
|
-
"moreDot": "TWf_pa_moreDot",
|
|
29
|
-
"qnPulse": "TWf_pa_qnPulse",
|
|
30
89
|
"rail": "TWf_pa_rail",
|
|
31
|
-
"tooltip": "TWf_pa_tooltip"
|
|
90
|
+
"tooltip": "TWf_pa_tooltip",
|
|
91
|
+
"tooltipLine": "TWf_pa_tooltipLine",
|
|
92
|
+
"tooltipTitle": "TWf_pa_tooltipTitle"
|
|
32
93
|
};
|
|
33
94
|
//#endregion
|
|
34
95
|
//#region src/client/QuestionNavStrip.tsx
|
|
35
96
|
/**
|
|
36
97
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
37
98
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
38
|
-
* `shell.overlay` floating layer), vertically centered: one dot per
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
99
|
+
* `shell.overlay` floating layer), vertically centered: one dot per turn that
|
|
100
|
+
* claimed at least one user question — strictly aligned with the Trajectory
|
|
101
|
+
* view's turn numbering (turns without a question produce no dot). Hover
|
|
102
|
+
* enlarges a dot and shows an instant tooltip (portal-rendered, no native
|
|
103
|
+
* delay) with the turn label and the turn's question text(s); clicking jumps
|
|
104
|
+
* the chat to that turn's first question.
|
|
42
105
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
106
|
+
* Data source: the host-folded `questionIndex` session projection (whole
|
|
107
|
+
* history, persisted host-side, pushed live through session/projection
|
|
108
|
+
* frames) read through the injected `questionProjection` face, plus the live
|
|
109
|
+
* chat window's questions merged on top for the brief window before a
|
|
110
|
+
* just-sent question lands in the projection. No render-window expansion, no
|
|
111
|
+
* client-side history paging.
|
|
48
112
|
*
|
|
49
|
-
* Data arrives through the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
113
|
+
* Data arrives through the props shares: the framework `useSessions` hook
|
|
114
|
+
* (current session), the registrant inject face (read/subscribe/project/
|
|
115
|
+
* jump), and the bound locale translator.
|
|
52
116
|
*/
|
|
53
117
|
const FAILURE_HINTS = {
|
|
54
118
|
VIEW_INACTIVE: "jump.inactive",
|
|
@@ -56,6 +120,12 @@ window.__ModuleLoader__.load({
|
|
|
56
120
|
NOT_FOUND: "jump.notfound",
|
|
57
121
|
TIMEOUT: "jump.timeout"
|
|
58
122
|
};
|
|
123
|
+
/** Read the projection face value as a question-entry list (structural guard). */
|
|
124
|
+
function projectionEntries(face) {
|
|
125
|
+
const value = face?.getSnapshot();
|
|
126
|
+
if (!Array.isArray(value)) return [];
|
|
127
|
+
return value.filter((item) => typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.seq === "number" && typeof item.turn === "number");
|
|
128
|
+
}
|
|
59
129
|
function findConvRoot() {
|
|
60
130
|
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
61
131
|
}
|
|
@@ -63,18 +133,12 @@ window.__ModuleLoader__.load({
|
|
|
63
133
|
const current = props.useSessions((s) => s.current);
|
|
64
134
|
const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
|
|
65
135
|
const visible = current !== void 0 && summary !== void 0 && summary.blank !== true;
|
|
66
|
-
const [
|
|
136
|
+
const [dots, setDots] = (0, react.useState)([]);
|
|
67
137
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
68
138
|
const [hint, setHint] = (0, react.useState)(null);
|
|
69
139
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
70
|
-
const [loadingAll, setLoadingAll] = (0, react.useState)(false);
|
|
71
|
-
const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
|
|
72
140
|
const panelRef = (0, react.useRef)(null);
|
|
73
141
|
const hintTimerRef = (0, react.useRef)(null);
|
|
74
|
-
/** Abort controller for the in-flight expansion (cancelled on session change). */
|
|
75
|
-
const loadAllAbortRef = (0, react.useRef)(null);
|
|
76
|
-
/** Session whose expansion is already running, to avoid duplicate loops. */
|
|
77
|
-
const loadingAllSessionRef = (0, react.useRef)(null);
|
|
78
142
|
const showHint = (message) => {
|
|
79
143
|
setHint(message);
|
|
80
144
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -82,14 +146,21 @@ window.__ModuleLoader__.load({
|
|
|
82
146
|
};
|
|
83
147
|
(0, react.useEffect)(() => {
|
|
84
148
|
if (!visible || current === void 0) {
|
|
85
|
-
|
|
149
|
+
setDots([]);
|
|
86
150
|
return;
|
|
87
151
|
}
|
|
88
|
-
const
|
|
152
|
+
const sessionId = current;
|
|
153
|
+
const face = props.questionProjection(sessionId);
|
|
154
|
+
const refresh = () => {
|
|
155
|
+
const grouped = groupQuestionsByTurn(projectionEntries(face));
|
|
156
|
+
setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
|
|
157
|
+
};
|
|
89
158
|
refresh();
|
|
90
|
-
const
|
|
159
|
+
const unsubProjection = face?.subscribe(refresh) ?? (() => {});
|
|
160
|
+
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
91
161
|
const unsubList = props.subscribeList(refresh);
|
|
92
162
|
return () => {
|
|
163
|
+
unsubProjection();
|
|
93
164
|
unsubContent();
|
|
94
165
|
unsubList();
|
|
95
166
|
};
|
|
@@ -98,36 +169,6 @@ window.__ModuleLoader__.load({
|
|
|
98
169
|
current,
|
|
99
170
|
props
|
|
100
171
|
]);
|
|
101
|
-
(0, react.useEffect)(() => {
|
|
102
|
-
if (!visible || current === void 0) {
|
|
103
|
-
loadAllAbortRef.current?.abort();
|
|
104
|
-
loadAllAbortRef.current = null;
|
|
105
|
-
loadingAllSessionRef.current = null;
|
|
106
|
-
setLoadingAll(false);
|
|
107
|
-
setMoreAvailable(false);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (loadingAllSessionRef.current === current) return;
|
|
111
|
-
loadingAllSessionRef.current = current;
|
|
112
|
-
const controller = new AbortController();
|
|
113
|
-
loadAllAbortRef.current = controller;
|
|
114
|
-
setLoadingAll(true);
|
|
115
|
-
setMoreAvailable(false);
|
|
116
|
-
props.loadAllOlder(current, { signal: controller.signal }).then((result) => {
|
|
117
|
-
setMoreAvailable(result.code === "BUDGET" && !result.ok);
|
|
118
|
-
}).finally(() => {
|
|
119
|
-
setLoadingAll(false);
|
|
120
|
-
if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null;
|
|
121
|
-
loadingAllSessionRef.current = null;
|
|
122
|
-
});
|
|
123
|
-
return () => {
|
|
124
|
-
controller.abort();
|
|
125
|
-
};
|
|
126
|
-
}, [
|
|
127
|
-
visible,
|
|
128
|
-
current,
|
|
129
|
-
props
|
|
130
|
-
]);
|
|
131
172
|
(0, react.useEffect)(() => {
|
|
132
173
|
const onJumpFailed = (event) => {
|
|
133
174
|
const code = event.detail;
|
|
@@ -176,19 +217,20 @@ window.__ModuleLoader__.load({
|
|
|
176
217
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
177
218
|
}, []);
|
|
178
219
|
if (!visible) return null;
|
|
179
|
-
const onJump = (
|
|
220
|
+
const onJump = (dot) => {
|
|
180
221
|
if (current === void 0) return;
|
|
181
|
-
setJumpingKey(
|
|
182
|
-
props.jump(current,
|
|
183
|
-
window.setTimeout(() => setJumpingKey((k) => k ===
|
|
222
|
+
setJumpingKey(dot.key);
|
|
223
|
+
props.jump(current, dot.key);
|
|
224
|
+
window.setTimeout(() => setJumpingKey((k) => k === dot.key ? null : k), 600);
|
|
184
225
|
};
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
const openTooltip = (dot, target) => {
|
|
227
|
+
const r = target.getBoundingClientRect();
|
|
228
|
+
setTooltip({
|
|
229
|
+
title: dot.turn === null ? null : `Turn ${dot.turn}`,
|
|
230
|
+
lines: dot.texts,
|
|
231
|
+
left: r.right + 10,
|
|
232
|
+
top: r.top
|
|
233
|
+
});
|
|
192
234
|
};
|
|
193
235
|
const t = props.t;
|
|
194
236
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -203,58 +245,36 @@ window.__ModuleLoader__.load({
|
|
|
203
245
|
}) : null,
|
|
204
246
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
205
247
|
className: question_nav_module_css_default.list,
|
|
206
|
-
children:
|
|
248
|
+
children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
207
249
|
className: question_nav_module_css_default.empty,
|
|
208
|
-
children:
|
|
250
|
+
children: t("strip.empty")
|
|
209
251
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
210
252
|
className: question_nav_module_css_default.dots,
|
|
211
|
-
children: [
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"aria-label": t("strip.loadEarlier"),
|
|
222
|
-
title: t("strip.loadEarlier"),
|
|
223
|
-
onMouseEnter: (e) => {
|
|
224
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
225
|
-
setTooltip({
|
|
226
|
-
text: t("strip.loadEarlier"),
|
|
227
|
-
left: r.right + 10,
|
|
228
|
-
top: r.top
|
|
229
|
-
});
|
|
230
|
-
},
|
|
231
|
-
onMouseLeave: () => setTooltip(null),
|
|
232
|
-
onClick: onLoadMore
|
|
233
|
-
}) : null,
|
|
234
|
-
questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
235
|
-
className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
236
|
-
"aria-label": node.text,
|
|
237
|
-
onMouseEnter: (e) => {
|
|
238
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
239
|
-
setTooltip({
|
|
240
|
-
text: node.text,
|
|
241
|
-
left: r.right + 10,
|
|
242
|
-
top: r.top
|
|
243
|
-
});
|
|
244
|
-
},
|
|
245
|
-
onMouseLeave: () => setTooltip(null),
|
|
246
|
-
onClick: () => onJump(node)
|
|
247
|
-
}, node.key))
|
|
248
|
-
]
|
|
253
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
254
|
+
className: question_nav_module_css_default.count,
|
|
255
|
+
children: dots.length
|
|
256
|
+
}), dots.map((dot) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
257
|
+
className: jumpingKey === dot.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
258
|
+
"aria-label": dot.texts[0] ?? "",
|
|
259
|
+
onMouseEnter: (e) => openTooltip(dot, e.currentTarget),
|
|
260
|
+
onMouseLeave: () => setTooltip(null),
|
|
261
|
+
onClick: () => onJump(dot)
|
|
262
|
+
}, dot.key))]
|
|
249
263
|
})
|
|
250
264
|
}),
|
|
251
|
-
tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.
|
|
265
|
+
tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
252
266
|
className: question_nav_module_css_default.tooltip,
|
|
253
267
|
style: {
|
|
254
268
|
left: tooltip.left,
|
|
255
269
|
top: tooltip.top
|
|
256
270
|
},
|
|
257
|
-
children: tooltip.
|
|
271
|
+
children: [tooltip.title !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
272
|
+
className: question_nav_module_css_default.tooltipTitle,
|
|
273
|
+
children: tooltip.title
|
|
274
|
+
}) : null, tooltip.lines.map((line, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
275
|
+
className: question_nav_module_css_default.tooltipLine,
|
|
276
|
+
children: line
|
|
277
|
+
}, index))]
|
|
258
278
|
}), document.body) : null
|
|
259
279
|
]
|
|
260
280
|
});
|
|
@@ -267,9 +287,6 @@ window.__ModuleLoader__.load({
|
|
|
267
287
|
*/
|
|
268
288
|
const zh = {
|
|
269
289
|
"strip.empty": "本会话还没有提问",
|
|
270
|
-
"strip.loadingAll": "正在加载全部历史…",
|
|
271
|
-
"strip.loadingSuffix": "…",
|
|
272
|
-
"strip.loadEarlier": "加载更早的问题",
|
|
273
290
|
"jump.inactive": "聊天视图未激活",
|
|
274
291
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
275
292
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
@@ -277,9 +294,6 @@ window.__ModuleLoader__.load({
|
|
|
277
294
|
};
|
|
278
295
|
const en = {
|
|
279
296
|
"strip.empty": "No questions in this session yet",
|
|
280
|
-
"strip.loadingAll": "Loading full history…",
|
|
281
|
-
"strip.loadingSuffix": "…",
|
|
282
|
-
"strip.loadEarlier": "Load earlier questions",
|
|
283
297
|
"jump.inactive": "Chat view is not active",
|
|
284
298
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
285
299
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
@@ -338,7 +352,7 @@ window.__ModuleLoader__.load({
|
|
|
338
352
|
* paging/timeout/fallback loop is unit-testable without a real browser or
|
|
339
353
|
* session. The browser half wires these ports to ctx.sessions + the DOM.
|
|
340
354
|
*/
|
|
341
|
-
const DEFAULTS
|
|
355
|
+
const DEFAULTS = {
|
|
342
356
|
totalTimeoutMs: 15e3,
|
|
343
357
|
maxPages: 100,
|
|
344
358
|
rowWaitMs: 8e3,
|
|
@@ -367,7 +381,7 @@ window.__ModuleLoader__.load({
|
|
|
367
381
|
*/
|
|
368
382
|
async function jumpToQuestion(ports, key, options = {}) {
|
|
369
383
|
const cfg = {
|
|
370
|
-
...DEFAULTS
|
|
384
|
+
...DEFAULTS,
|
|
371
385
|
...options
|
|
372
386
|
};
|
|
373
387
|
const fail = (code, fallback = false) => {
|
|
@@ -433,72 +447,6 @@ window.__ModuleLoader__.load({
|
|
|
433
447
|
return fail("TARGET_HIDDEN", false);
|
|
434
448
|
}
|
|
435
449
|
//#endregion
|
|
436
|
-
//#region src/core/load-all.ts
|
|
437
|
-
const DEFAULTS = {
|
|
438
|
-
maxPages: 400,
|
|
439
|
-
totalTimeoutMs: 6e4,
|
|
440
|
-
pollMs: 60
|
|
441
|
-
};
|
|
442
|
-
/**
|
|
443
|
-
* Expand the session window backwards until the earliest history is loaded.
|
|
444
|
-
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
445
|
-
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
446
|
-
* immediately with `COMPLETE`.
|
|
447
|
-
*/
|
|
448
|
-
async function loadAllOlder(ports, options = {}) {
|
|
449
|
-
const cfg = {
|
|
450
|
-
...DEFAULTS,
|
|
451
|
-
...options
|
|
452
|
-
};
|
|
453
|
-
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
454
|
-
let pages = 0;
|
|
455
|
-
const cancelled = () => cfg.signal?.aborted === true;
|
|
456
|
-
while (true) {
|
|
457
|
-
if (cancelled()) return {
|
|
458
|
-
ok: false,
|
|
459
|
-
code: "CANCELLED",
|
|
460
|
-
pages
|
|
461
|
-
};
|
|
462
|
-
if (!ports.isViewActive()) return {
|
|
463
|
-
ok: false,
|
|
464
|
-
code: "VIEW_INACTIVE",
|
|
465
|
-
pages
|
|
466
|
-
};
|
|
467
|
-
const snap = ports.snapshot();
|
|
468
|
-
if (snap === void 0) return {
|
|
469
|
-
ok: false,
|
|
470
|
-
code: "VIEW_INACTIVE",
|
|
471
|
-
pages
|
|
472
|
-
};
|
|
473
|
-
if (snap.openState === "error") return {
|
|
474
|
-
ok: false,
|
|
475
|
-
code: "NOT_OPEN",
|
|
476
|
-
pages
|
|
477
|
-
};
|
|
478
|
-
if (snap.hasMore !== true) return {
|
|
479
|
-
ok: true,
|
|
480
|
-
code: "COMPLETE",
|
|
481
|
-
pages
|
|
482
|
-
};
|
|
483
|
-
if (pages >= cfg.maxPages) return {
|
|
484
|
-
ok: false,
|
|
485
|
-
code: "BUDGET",
|
|
486
|
-
pages
|
|
487
|
-
};
|
|
488
|
-
if (ports.now() > deadline) return {
|
|
489
|
-
ok: false,
|
|
490
|
-
code: "TIMEOUT",
|
|
491
|
-
pages
|
|
492
|
-
};
|
|
493
|
-
if (snap.openState !== "open" || snap.loadingOlder) {
|
|
494
|
-
await ports.sleep(cfg.pollMs);
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
await ports.loadOlder();
|
|
498
|
-
pages += 1;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
//#endregion
|
|
502
450
|
//#region src/client/index.ts
|
|
503
451
|
/** Locale namespace this plugin owns. */
|
|
504
452
|
const NS = "question-nav";
|
|
@@ -544,50 +492,19 @@ window.__ModuleLoader__.load({
|
|
|
544
492
|
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
545
493
|
};
|
|
546
494
|
}
|
|
547
|
-
/** Resolve the active conversation scrollport (or null when not mounted). */
|
|
548
|
-
function scrollport() {
|
|
549
|
-
return document.querySelector("[data-conversation-scroll]");
|
|
550
|
-
}
|
|
551
495
|
/**
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* rows down. We restore by the exact growth of the scrollHeight.
|
|
496
|
+
* The session's `questionIndex` projection face (getSnapshot + subscribe).
|
|
497
|
+
* Undefined when the session is not bound or the host unit is not registered
|
|
498
|
+
* (e.g. a headless composition) — the strip then shows live-window dots only.
|
|
556
499
|
*/
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
560
|
-
const port = scrollport();
|
|
561
|
-
const beforeHeight = port?.scrollHeight ?? 0;
|
|
562
|
-
const beforeTop = port?.scrollTop ?? 0;
|
|
563
|
-
await binding.session.loadOlder();
|
|
564
|
-
if (port === null) return;
|
|
565
|
-
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
566
|
-
const delta = port.scrollHeight - beforeHeight;
|
|
567
|
-
if (delta > 0) port.scrollTop = beforeTop + delta;
|
|
568
|
-
}
|
|
569
|
-
/** Map the session to the load-all port surface. */
|
|
570
|
-
function loadAllPortsFor(ctx, sessionId) {
|
|
500
|
+
function questionProjectionOf(ctx, sessionId) {
|
|
501
|
+
const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf("questionIndex");
|
|
502
|
+
if (face === void 0) return void 0;
|
|
571
503
|
return {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (snap === void 0) return void 0;
|
|
575
|
-
return {
|
|
576
|
-
openState: snap.openState,
|
|
577
|
-
hasMore: snap.hasMore,
|
|
578
|
-
loadingOlder: snap.loadingOlder
|
|
579
|
-
};
|
|
580
|
-
},
|
|
581
|
-
loadOlder: () => pagedLoadOlder(ctx, sessionId),
|
|
582
|
-
isViewActive: () => document.querySelector("[data-chat-flow]") !== null,
|
|
583
|
-
now: () => Date.now(),
|
|
584
|
-
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
504
|
+
getSnapshot: () => face.getSnapshot(),
|
|
505
|
+
subscribe: (listener) => face.subscribe(listener)
|
|
585
506
|
};
|
|
586
507
|
}
|
|
587
|
-
/** Expand the whole session history so every question becomes a dot. */
|
|
588
|
-
function loadAllFor(ctx, sessionId, options = {}) {
|
|
589
|
-
return loadAllOlder(loadAllPortsFor(ctx, sessionId), options);
|
|
590
|
-
}
|
|
591
508
|
function createInject(ctx) {
|
|
592
509
|
return {
|
|
593
510
|
readQuestions: (sessionId) => {
|
|
@@ -601,14 +518,14 @@ window.__ModuleLoader__.load({
|
|
|
601
518
|
if (binding === void 0) return () => {};
|
|
602
519
|
return binding.session.subscribe(cb);
|
|
603
520
|
},
|
|
521
|
+
questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),
|
|
604
522
|
jump: (sessionId, key) => {
|
|
605
523
|
const ports = jumpPortsFor(ctx, sessionId);
|
|
606
524
|
ports.report = (code) => {
|
|
607
525
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
608
526
|
};
|
|
609
527
|
jumpToQuestion(ports, key);
|
|
610
|
-
}
|
|
611
|
-
loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options)
|
|
528
|
+
}
|
|
612
529
|
};
|
|
613
530
|
}
|
|
614
531
|
/**
|