@luziyang2026/dsh-question-nav 0.2.0 → 0.3.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 +255 -174
- package/lib/client.js.map +1 -1
- package/lib/types/client/QuestionNavStrip.d.ts +5 -5
- package/lib/types/client/index.d.ts +8 -3
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/nodes.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/QuestionNavStrip.tsx +81 -59
- package/src/client/index.ts +41 -42
- package/src/core/history-index.ts +176 -0
- package/src/core/nodes.ts +14 -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,6 +7,62 @@ 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/nodes.ts
|
|
11
|
+
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
12
|
+
const QUESTION_KINDS = ["user", "steering"];
|
|
13
|
+
function userData(data) {
|
|
14
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
/** First text block of a user message; falls back to the raw first block. */
|
|
18
|
+
function messageText(content) {
|
|
19
|
+
if (content === void 0 || content.length === 0) return "";
|
|
20
|
+
const first = content[0];
|
|
21
|
+
if (typeof first?.text === "string") return first.text;
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
25
|
+
function extractQuestions(nodes) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const node of nodes) {
|
|
28
|
+
if (!QUESTION_KINDS.includes(node.kind)) continue;
|
|
29
|
+
const payload = userData(node.data);
|
|
30
|
+
out.push({
|
|
31
|
+
key: node.key,
|
|
32
|
+
anchorSeq: node.anchorSeq,
|
|
33
|
+
seq: payload?.seq ?? -1,
|
|
34
|
+
time: payload?.time ?? 0,
|
|
35
|
+
text: messageText(payload?.content)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
out.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
42
|
+
function nearestRenderable(nodes, excludeKey) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const node of nodes) {
|
|
45
|
+
if (node.visibility === "hidden") continue;
|
|
46
|
+
if (node.key === excludeKey) continue;
|
|
47
|
+
if (best === null || node.anchorSeq < best.anchorSeq) best = {
|
|
48
|
+
key: node.key,
|
|
49
|
+
anchorSeq: node.anchorSeq
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Merge two question sets (full-history index + live loaded window) into one
|
|
56
|
+
* deduplicated, anchorSeq-ascending list. The window may hold questions that
|
|
57
|
+
* arrived after the index was built; the index may hold questions the window
|
|
58
|
+
* has not loaded yet — union on `key`, newest live copy wins per key.
|
|
59
|
+
*/
|
|
60
|
+
function mergeQuestions(...sources) {
|
|
61
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
62
|
+
for (const source of sources) for (const node of source) byKey.set(node.key, node);
|
|
63
|
+
return [...byKey.values()].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
10
66
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
11
67
|
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.TWf_pa_moreDot{border:1px dashed var(--dsw-alias-border-l3);background:0 0}.TWf_pa_moreDot:hover{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_countLoading{color:var(--dsw-alias-brand-primary);margin-left:2px;font-weight:400;animation:1.2s ease-in-out infinite TWf_pa_qnPulse}@keyframes TWf_pa_qnPulse{0%,to{opacity:.4}50%{opacity:1}}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}";
|
|
12
68
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
@@ -40,15 +96,19 @@ window.__ModuleLoader__.load({
|
|
|
40
96
|
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
41
97
|
* the chat to that question.
|
|
42
98
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
99
|
+
* Index strategy (no render-window expansion): the dots cover the WHOLE
|
|
100
|
+
* session history. The index is built from the raw `session.history` RPC via
|
|
101
|
+
* the injected `fetchQuestionIndex` — the conversation's paged window is
|
|
102
|
+
* untouched, so DSH's memory economy is preserved. The loaded window's live
|
|
103
|
+
* questions are merged on top (for new messages arriving after the index was
|
|
104
|
+
* built). Clicking a dot jumps through the existing paging loop, which calls
|
|
105
|
+
* `loadOlder()` only until that specific page is in the window. If the index
|
|
106
|
+
* safety budget is exhausted, a dimmed dashed "load earlier" dot appears above
|
|
107
|
+
* the oldest question and continues the index on click.
|
|
48
108
|
*
|
|
49
109
|
* Data arrives through the four props shares: the framework `useSessions`
|
|
50
110
|
* hook (current session), the registrant inject face (read/subscribe/jump/
|
|
51
|
-
*
|
|
111
|
+
* fetch-index), and the bound locale translator.
|
|
52
112
|
*/
|
|
53
113
|
const FAILURE_HINTS = {
|
|
54
114
|
VIEW_INACTIVE: "jump.inactive",
|
|
@@ -67,14 +127,18 @@ window.__ModuleLoader__.load({
|
|
|
67
127
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
68
128
|
const [hint, setHint] = (0, react.useState)(null);
|
|
69
129
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
70
|
-
const [
|
|
130
|
+
const [loadingIndex, setLoadingIndex] = (0, react.useState)(false);
|
|
71
131
|
const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
|
|
72
132
|
const panelRef = (0, react.useRef)(null);
|
|
73
133
|
const hintTimerRef = (0, react.useRef)(null);
|
|
74
|
-
/**
|
|
75
|
-
const
|
|
76
|
-
/**
|
|
77
|
-
const
|
|
134
|
+
/** Full-history index from the raw RPC (per current session). */
|
|
135
|
+
const indexRef = (0, react.useRef)([]);
|
|
136
|
+
/** Next beforeSeq to resume from when the index budget was exhausted. */
|
|
137
|
+
const nextBeforeSeqRef = (0, react.useRef)(void 0);
|
|
138
|
+
/** Abort controller for the in-flight index build. */
|
|
139
|
+
const indexAbortRef = (0, react.useRef)(null);
|
|
140
|
+
/** Session whose index build is in flight, to avoid duplicate loops. */
|
|
141
|
+
const buildingSessionRef = (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,53 @@ window.__ModuleLoader__.load({
|
|
|
82
146
|
};
|
|
83
147
|
(0, react.useEffect)(() => {
|
|
84
148
|
if (!visible || current === void 0) {
|
|
149
|
+
indexRef.current = [];
|
|
150
|
+
nextBeforeSeqRef.current = void 0;
|
|
151
|
+
indexAbortRef.current?.abort();
|
|
152
|
+
indexAbortRef.current = null;
|
|
153
|
+
buildingSessionRef.current = null;
|
|
85
154
|
setQuestions([]);
|
|
155
|
+
setLoadingIndex(false);
|
|
156
|
+
setMoreAvailable(false);
|
|
86
157
|
return;
|
|
87
158
|
}
|
|
88
|
-
const
|
|
159
|
+
const sessionId = current;
|
|
160
|
+
indexRef.current = [];
|
|
161
|
+
nextBeforeSeqRef.current = void 0;
|
|
162
|
+
const refresh = () => {
|
|
163
|
+
const windowQuestions = props.readQuestions(sessionId);
|
|
164
|
+
setQuestions(mergeQuestions(indexRef.current, windowQuestions));
|
|
165
|
+
};
|
|
166
|
+
const startBuild = (options) => {
|
|
167
|
+
buildingSessionRef.current = sessionId;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
indexAbortRef.current = controller;
|
|
170
|
+
setLoadingIndex(true);
|
|
171
|
+
setMoreAvailable(false);
|
|
172
|
+
props.fetchQuestionIndex(sessionId, {
|
|
173
|
+
...options,
|
|
174
|
+
signal: controller.signal
|
|
175
|
+
}).then((result) => {
|
|
176
|
+
if (buildingSessionRef.current !== sessionId) return;
|
|
177
|
+
indexRef.current = mergeQuestions(result.questions, indexRef.current);
|
|
178
|
+
nextBeforeSeqRef.current = result.nextBeforeSeq;
|
|
179
|
+
setMoreAvailable(result.code === "BUDGET" && result.nextBeforeSeq !== void 0);
|
|
180
|
+
refresh();
|
|
181
|
+
}).finally(() => {
|
|
182
|
+
if (buildingSessionRef.current === sessionId) {
|
|
183
|
+
setLoadingIndex(false);
|
|
184
|
+
if (indexAbortRef.current === controller) indexAbortRef.current = null;
|
|
185
|
+
buildingSessionRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
};
|
|
89
189
|
refresh();
|
|
90
|
-
|
|
190
|
+
startBuild();
|
|
191
|
+
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
91
192
|
const unsubList = props.subscribeList(refresh);
|
|
92
193
|
return () => {
|
|
194
|
+
indexAbortRef.current?.abort();
|
|
195
|
+
indexAbortRef.current = null;
|
|
93
196
|
unsubContent();
|
|
94
197
|
unsubList();
|
|
95
198
|
};
|
|
@@ -98,36 +201,6 @@ window.__ModuleLoader__.load({
|
|
|
98
201
|
current,
|
|
99
202
|
props
|
|
100
203
|
]);
|
|
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
204
|
(0, react.useEffect)(() => {
|
|
132
205
|
const onJumpFailed = (event) => {
|
|
133
206
|
const code = event.detail;
|
|
@@ -183,12 +256,16 @@ window.__ModuleLoader__.load({
|
|
|
183
256
|
window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
|
|
184
257
|
};
|
|
185
258
|
const onLoadMore = () => {
|
|
186
|
-
if (current === void 0) return;
|
|
259
|
+
if (current === void 0 || nextBeforeSeqRef.current === void 0) return;
|
|
187
260
|
setMoreAvailable(false);
|
|
188
|
-
|
|
189
|
-
props.
|
|
190
|
-
|
|
191
|
-
|
|
261
|
+
setLoadingIndex(true);
|
|
262
|
+
props.fetchQuestionIndex(current, { startBeforeSeq: nextBeforeSeqRef.current }).then((result) => {
|
|
263
|
+
if (current === void 0) return;
|
|
264
|
+
indexRef.current = mergeQuestions(result.questions, indexRef.current);
|
|
265
|
+
nextBeforeSeqRef.current = result.nextBeforeSeq;
|
|
266
|
+
setMoreAvailable(result.code === "BUDGET" && result.nextBeforeSeq !== void 0);
|
|
267
|
+
setQuestions(mergeQuestions(indexRef.current, props.readQuestions(current)));
|
|
268
|
+
}).finally(() => setLoadingIndex(false));
|
|
192
269
|
};
|
|
193
270
|
const t = props.t;
|
|
194
271
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -205,18 +282,18 @@ window.__ModuleLoader__.load({
|
|
|
205
282
|
className: question_nav_module_css_default.list,
|
|
206
283
|
children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
207
284
|
className: question_nav_module_css_default.empty,
|
|
208
|
-
children:
|
|
285
|
+
children: loadingIndex ? t("strip.loadingAll") : t("strip.empty")
|
|
209
286
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
210
287
|
className: question_nav_module_css_default.dots,
|
|
211
288
|
children: [
|
|
212
289
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
213
290
|
className: question_nav_module_css_default.count,
|
|
214
|
-
children: [questions.length,
|
|
291
|
+
children: [questions.length, loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
215
292
|
className: question_nav_module_css_default.countLoading,
|
|
216
293
|
children: t("strip.loadingSuffix")
|
|
217
294
|
}) : null]
|
|
218
295
|
}),
|
|
219
|
-
moreAvailable && !
|
|
296
|
+
moreAvailable && !loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
220
297
|
className: `${question_nav_module_css_default.dot} ${question_nav_module_css_default.moreDot}`,
|
|
221
298
|
"aria-label": t("strip.loadEarlier"),
|
|
222
299
|
title: t("strip.loadEarlier"),
|
|
@@ -286,51 +363,6 @@ window.__ModuleLoader__.load({
|
|
|
286
363
|
"jump.timeout": "Timed out loading history; retry"
|
|
287
364
|
};
|
|
288
365
|
//#endregion
|
|
289
|
-
//#region src/core/nodes.ts
|
|
290
|
-
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
291
|
-
const QUESTION_KINDS = ["user", "steering"];
|
|
292
|
-
function userData(data) {
|
|
293
|
-
if (typeof data !== "object" || data === null) return void 0;
|
|
294
|
-
return data;
|
|
295
|
-
}
|
|
296
|
-
/** First text block of a user message; falls back to the raw first block. */
|
|
297
|
-
function messageText(content) {
|
|
298
|
-
if (content === void 0 || content.length === 0) return "";
|
|
299
|
-
const first = content[0];
|
|
300
|
-
if (typeof first?.text === "string") return first.text;
|
|
301
|
-
return "";
|
|
302
|
-
}
|
|
303
|
-
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
304
|
-
function extractQuestions(nodes) {
|
|
305
|
-
const out = [];
|
|
306
|
-
for (const node of nodes) {
|
|
307
|
-
if (!QUESTION_KINDS.includes(node.kind)) continue;
|
|
308
|
-
const payload = userData(node.data);
|
|
309
|
-
out.push({
|
|
310
|
-
key: node.key,
|
|
311
|
-
anchorSeq: node.anchorSeq,
|
|
312
|
-
seq: payload?.seq ?? -1,
|
|
313
|
-
time: payload?.time ?? 0,
|
|
314
|
-
text: messageText(payload?.content)
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
out.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
318
|
-
return out;
|
|
319
|
-
}
|
|
320
|
-
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
321
|
-
function nearestRenderable(nodes, excludeKey) {
|
|
322
|
-
let best = null;
|
|
323
|
-
for (const node of nodes) {
|
|
324
|
-
if (node.visibility === "hidden") continue;
|
|
325
|
-
if (node.key === excludeKey) continue;
|
|
326
|
-
if (best === null || node.anchorSeq < best.anchorSeq) best = {
|
|
327
|
-
key: node.key,
|
|
328
|
-
anchorSeq: node.anchorSeq
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
return best;
|
|
332
|
-
}
|
|
333
|
-
//#endregion
|
|
334
366
|
//#region src/core/jump.ts
|
|
335
367
|
/**
|
|
336
368
|
* Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
|
|
@@ -433,68 +465,129 @@ window.__ModuleLoader__.load({
|
|
|
433
465
|
return fail("TARGET_HIDDEN", false);
|
|
434
466
|
}
|
|
435
467
|
//#endregion
|
|
436
|
-
//#region src/core/
|
|
468
|
+
//#region src/core/history-index.ts
|
|
469
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
470
|
+
const MESSAGE_DEFINITION_KIND = "input-message";
|
|
471
|
+
/**
|
|
472
|
+
* The engine-owned stable chat key for a user question event — mirrors
|
|
473
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
474
|
+
* (verified against it in the unit test).
|
|
475
|
+
*/
|
|
476
|
+
function questionKey(id) {
|
|
477
|
+
return `13:${MESSAGE_DEFINITION_KIND}${String(id)}`;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether a raw event is one user question the strip should index.
|
|
481
|
+
* Mirrors the DSH `messageDefinition` match + `start` classification:
|
|
482
|
+
* an append-origin `user/message` with a human (`user`) source. Replacement
|
|
483
|
+
* copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
|
|
484
|
+
* context (`source.kind !== 'user'`) are excluded.
|
|
485
|
+
*/
|
|
486
|
+
function isQuestionEvent(event) {
|
|
487
|
+
if (event.type !== "user/message") return false;
|
|
488
|
+
if (event.surfaceOp !== "append") return false;
|
|
489
|
+
return event.data?.source?.kind === "user";
|
|
490
|
+
}
|
|
491
|
+
/** Map one raw question event to a strip question node, or null when not one. */
|
|
492
|
+
function questionFromEvent(event) {
|
|
493
|
+
if (!isQuestionEvent(event)) return null;
|
|
494
|
+
return {
|
|
495
|
+
key: questionKey(event.data?.id),
|
|
496
|
+
anchorSeq: event.seq,
|
|
497
|
+
seq: event.seq,
|
|
498
|
+
time: event.time,
|
|
499
|
+
text: messageText(event.data?.content)
|
|
500
|
+
};
|
|
501
|
+
}
|
|
437
502
|
const DEFAULTS = {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
503
|
+
maxMessages: 100,
|
|
504
|
+
maxPages: 200,
|
|
505
|
+
totalTimeoutMs: 3e4
|
|
441
506
|
};
|
|
507
|
+
function minSeq(events) {
|
|
508
|
+
let min;
|
|
509
|
+
for (const { event } of events) if (min === void 0 || event.seq < min) min = event.seq;
|
|
510
|
+
return min;
|
|
511
|
+
}
|
|
442
512
|
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
446
|
-
* immediately with `COMPLETE`.
|
|
513
|
+
* Page the raw session history backward, collecting every user question into a
|
|
514
|
+
* lightweight index. Never touches the render window.
|
|
447
515
|
*/
|
|
448
|
-
async function
|
|
516
|
+
async function buildQuestionIndex(ports, options = {}) {
|
|
449
517
|
const cfg = {
|
|
450
518
|
...DEFAULTS,
|
|
451
519
|
...options
|
|
452
520
|
};
|
|
453
521
|
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
522
|
+
const questions = [];
|
|
523
|
+
let beforeSeq = cfg.startBeforeSeq;
|
|
454
524
|
let pages = 0;
|
|
455
525
|
const cancelled = () => cfg.signal?.aborted === true;
|
|
456
526
|
while (true) {
|
|
457
527
|
if (cancelled()) return {
|
|
458
528
|
ok: false,
|
|
459
529
|
code: "CANCELLED",
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
530
|
+
questions,
|
|
531
|
+
pages,
|
|
532
|
+
nextBeforeSeq: beforeSeq
|
|
472
533
|
};
|
|
473
|
-
if (
|
|
534
|
+
if (ports.now() > deadline) return {
|
|
474
535
|
ok: false,
|
|
475
|
-
code: "
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
ok: true,
|
|
480
|
-
code: "COMPLETE",
|
|
481
|
-
pages
|
|
536
|
+
code: "TIMEOUT",
|
|
537
|
+
questions,
|
|
538
|
+
pages,
|
|
539
|
+
nextBeforeSeq: beforeSeq
|
|
482
540
|
};
|
|
483
541
|
if (pages >= cfg.maxPages) return {
|
|
484
542
|
ok: false,
|
|
485
543
|
code: "BUDGET",
|
|
486
|
-
|
|
544
|
+
questions,
|
|
545
|
+
pages,
|
|
546
|
+
nextBeforeSeq: beforeSeq
|
|
487
547
|
};
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
548
|
+
const page = await ports.history(beforeSeq, cfg.maxMessages);
|
|
549
|
+
if (page === void 0) {
|
|
550
|
+
if (pages === 0) return {
|
|
551
|
+
ok: false,
|
|
552
|
+
code: "UNAVAILABLE",
|
|
553
|
+
questions,
|
|
554
|
+
pages,
|
|
555
|
+
nextBeforeSeq: beforeSeq
|
|
556
|
+
};
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
code: "COMPLETE",
|
|
560
|
+
questions,
|
|
561
|
+
pages,
|
|
562
|
+
nextBeforeSeq: void 0
|
|
563
|
+
};
|
|
496
564
|
}
|
|
497
|
-
|
|
565
|
+
for (const { event } of page.events) {
|
|
566
|
+
const question = questionFromEvent(event);
|
|
567
|
+
if (question !== null) questions.push(question);
|
|
568
|
+
}
|
|
569
|
+
if (!page.hasMore) {
|
|
570
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
code: "COMPLETE",
|
|
574
|
+
questions,
|
|
575
|
+
pages,
|
|
576
|
+
nextBeforeSeq: void 0
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const next = minSeq(page.events);
|
|
580
|
+
if (next === void 0) {
|
|
581
|
+
questions.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
code: "COMPLETE",
|
|
585
|
+
questions,
|
|
586
|
+
pages,
|
|
587
|
+
nextBeforeSeq: void 0
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
beforeSeq = next;
|
|
498
591
|
pages += 1;
|
|
499
592
|
}
|
|
500
593
|
}
|
|
@@ -506,7 +599,8 @@ window.__ModuleLoader__.load({
|
|
|
506
599
|
const inject = [
|
|
507
600
|
"slots",
|
|
508
601
|
"locale",
|
|
509
|
-
"sessions"
|
|
602
|
+
"sessions",
|
|
603
|
+
"connection"
|
|
510
604
|
];
|
|
511
605
|
function claimApply() {
|
|
512
606
|
if (globalThis.__dshQuestionNavApplied === true) return false;
|
|
@@ -544,49 +638,36 @@ window.__ModuleLoader__.load({
|
|
|
544
638
|
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
545
639
|
};
|
|
546
640
|
}
|
|
547
|
-
/** Resolve the
|
|
548
|
-
function
|
|
549
|
-
return
|
|
641
|
+
/** Resolve the connection handle (shared API client) as other DSH plugins do. */
|
|
642
|
+
function connectionOf(ctx) {
|
|
643
|
+
return ctx.get("connection");
|
|
550
644
|
}
|
|
551
645
|
/**
|
|
552
|
-
* One
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
646
|
+
* One raw history page, mapped to the pure `buildQuestionIndex` port shape.
|
|
647
|
+
* `beforeSeq` is exclusive; `undefined` reads the newest page. Returns
|
|
648
|
+
* undefined when the page is unavailable so the builder stops cleanly. The
|
|
649
|
+
* SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
|
|
650
|
+
* boundary (the index reader only touches type/seq/time/surfaceOp/data).
|
|
556
651
|
*/
|
|
557
|
-
async function
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (
|
|
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) {
|
|
652
|
+
async function rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages) {
|
|
653
|
+
const { api } = connectionOf(ctx);
|
|
654
|
+
const { result } = await api.sessions.history({
|
|
655
|
+
sessionId,
|
|
656
|
+
beforeSeq,
|
|
657
|
+
maxMessages
|
|
658
|
+
});
|
|
659
|
+
if (!result.ok) return void 0;
|
|
571
660
|
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))
|
|
661
|
+
events: result.value.events.map((entry) => ({ event: entry.event })),
|
|
662
|
+
hasMore: result.value.hasMore
|
|
585
663
|
};
|
|
586
664
|
}
|
|
587
|
-
/**
|
|
588
|
-
function
|
|
589
|
-
return
|
|
665
|
+
/** Build the full-session question index from the raw history RPC (no render). */
|
|
666
|
+
function buildIndexFor(ctx, sessionId, options = {}) {
|
|
667
|
+
return buildQuestionIndex({
|
|
668
|
+
history: (beforeSeq, maxMessages) => rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages),
|
|
669
|
+
now: () => Date.now()
|
|
670
|
+
}, options);
|
|
590
671
|
}
|
|
591
672
|
function createInject(ctx) {
|
|
592
673
|
return {
|
|
@@ -608,7 +689,7 @@ window.__ModuleLoader__.load({
|
|
|
608
689
|
};
|
|
609
690
|
jumpToQuestion(ports, key);
|
|
610
691
|
},
|
|
611
|
-
|
|
692
|
+
fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options)
|
|
612
693
|
};
|
|
613
694
|
}
|
|
614
695
|
/**
|