@luziyang2026/dsh-question-nav 0.3.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/lib/client.js +176 -340
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +9 -3
- package/lib/types/client/index.d.ts +9 -8
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/nodes.d.ts +0 -7
- 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 +76 -118
- package/src/client/index.ts +20 -46
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/nodes.ts +0 -14
- 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/history-index.ts +0 -176
package/lib/client.js
CHANGED
|
@@ -7,64 +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/
|
|
11
|
-
/**
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
const first = content[0];
|
|
21
|
-
if (typeof first?.text === "string") return first.text;
|
|
22
|
-
return "";
|
|
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)}`;
|
|
23
20
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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]
|
|
36
47
|
});
|
|
37
48
|
}
|
|
38
|
-
|
|
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;
|
|
49
|
+
return dots;
|
|
53
50
|
}
|
|
54
51
|
/**
|
|
55
|
-
* Merge
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
59
57
|
*/
|
|
60
|
-
function
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
70
|
}
|
|
65
71
|
//#endregion
|
|
66
72
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
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)}.
|
|
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}";
|
|
68
74
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
69
75
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
70
76
|
const tag = document.createElement("style");
|
|
@@ -76,39 +82,37 @@ window.__ModuleLoader__.load({
|
|
|
76
82
|
var question_nav_module_css_default = {
|
|
77
83
|
"active": "TWf_pa_active",
|
|
78
84
|
"count": "TWf_pa_count",
|
|
79
|
-
"countLoading": "TWf_pa_countLoading",
|
|
80
85
|
"dot": "TWf_pa_dot",
|
|
81
86
|
"dots": "TWf_pa_dots",
|
|
82
87
|
"empty": "TWf_pa_empty",
|
|
83
88
|
"list": "TWf_pa_list",
|
|
84
|
-
"moreDot": "TWf_pa_moreDot",
|
|
85
|
-
"qnPulse": "TWf_pa_qnPulse",
|
|
86
89
|
"rail": "TWf_pa_rail",
|
|
87
|
-
"tooltip": "TWf_pa_tooltip"
|
|
90
|
+
"tooltip": "TWf_pa_tooltip",
|
|
91
|
+
"tooltipLine": "TWf_pa_tooltipLine",
|
|
92
|
+
"tooltipTitle": "TWf_pa_tooltipTitle"
|
|
88
93
|
};
|
|
89
94
|
//#endregion
|
|
90
95
|
//#region src/client/QuestionNavStrip.tsx
|
|
91
96
|
/**
|
|
92
97
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
93
98
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
94
|
-
* `shell.overlay` floating layer), vertically centered: one dot per
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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.
|
|
98
105
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the injected `
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
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.
|
|
108
112
|
*
|
|
109
|
-
* Data arrives through the
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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.
|
|
112
116
|
*/
|
|
113
117
|
const FAILURE_HINTS = {
|
|
114
118
|
VIEW_INACTIVE: "jump.inactive",
|
|
@@ -116,6 +120,12 @@ window.__ModuleLoader__.load({
|
|
|
116
120
|
NOT_FOUND: "jump.notfound",
|
|
117
121
|
TIMEOUT: "jump.timeout"
|
|
118
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
|
+
}
|
|
119
129
|
function findConvRoot() {
|
|
120
130
|
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
121
131
|
}
|
|
@@ -123,22 +133,12 @@ window.__ModuleLoader__.load({
|
|
|
123
133
|
const current = props.useSessions((s) => s.current);
|
|
124
134
|
const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
|
|
125
135
|
const visible = current !== void 0 && summary !== void 0 && summary.blank !== true;
|
|
126
|
-
const [
|
|
136
|
+
const [dots, setDots] = (0, react.useState)([]);
|
|
127
137
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
128
138
|
const [hint, setHint] = (0, react.useState)(null);
|
|
129
139
|
const [tooltip, setTooltip] = (0, react.useState)(null);
|
|
130
|
-
const [loadingIndex, setLoadingIndex] = (0, react.useState)(false);
|
|
131
|
-
const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
|
|
132
140
|
const panelRef = (0, react.useRef)(null);
|
|
133
141
|
const hintTimerRef = (0, react.useRef)(null);
|
|
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);
|
|
142
142
|
const showHint = (message) => {
|
|
143
143
|
setHint(message);
|
|
144
144
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -146,53 +146,21 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
};
|
|
147
147
|
(0, react.useEffect)(() => {
|
|
148
148
|
if (!visible || current === void 0) {
|
|
149
|
-
|
|
150
|
-
nextBeforeSeqRef.current = void 0;
|
|
151
|
-
indexAbortRef.current?.abort();
|
|
152
|
-
indexAbortRef.current = null;
|
|
153
|
-
buildingSessionRef.current = null;
|
|
154
|
-
setQuestions([]);
|
|
155
|
-
setLoadingIndex(false);
|
|
156
|
-
setMoreAvailable(false);
|
|
149
|
+
setDots([]);
|
|
157
150
|
return;
|
|
158
151
|
}
|
|
159
152
|
const sessionId = current;
|
|
160
|
-
|
|
161
|
-
nextBeforeSeqRef.current = void 0;
|
|
153
|
+
const face = props.questionProjection(sessionId);
|
|
162
154
|
const refresh = () => {
|
|
163
|
-
const
|
|
164
|
-
|
|
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
|
-
});
|
|
155
|
+
const grouped = groupQuestionsByTurn(projectionEntries(face));
|
|
156
|
+
setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
|
|
188
157
|
};
|
|
189
158
|
refresh();
|
|
190
|
-
|
|
159
|
+
const unsubProjection = face?.subscribe(refresh) ?? (() => {});
|
|
191
160
|
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
192
161
|
const unsubList = props.subscribeList(refresh);
|
|
193
162
|
return () => {
|
|
194
|
-
|
|
195
|
-
indexAbortRef.current = null;
|
|
163
|
+
unsubProjection();
|
|
196
164
|
unsubContent();
|
|
197
165
|
unsubList();
|
|
198
166
|
};
|
|
@@ -249,23 +217,20 @@ window.__ModuleLoader__.load({
|
|
|
249
217
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
250
218
|
}, []);
|
|
251
219
|
if (!visible) return null;
|
|
252
|
-
const onJump = (
|
|
220
|
+
const onJump = (dot) => {
|
|
253
221
|
if (current === void 0) return;
|
|
254
|
-
setJumpingKey(
|
|
255
|
-
props.jump(current,
|
|
256
|
-
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);
|
|
257
225
|
};
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
setMoreAvailable(result.code === "BUDGET" && result.nextBeforeSeq !== void 0);
|
|
267
|
-
setQuestions(mergeQuestions(indexRef.current, props.readQuestions(current)));
|
|
268
|
-
}).finally(() => setLoadingIndex(false));
|
|
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
|
+
});
|
|
269
234
|
};
|
|
270
235
|
const t = props.t;
|
|
271
236
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -280,58 +245,36 @@ window.__ModuleLoader__.load({
|
|
|
280
245
|
}) : null,
|
|
281
246
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
282
247
|
className: question_nav_module_css_default.list,
|
|
283
|
-
children:
|
|
248
|
+
children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
284
249
|
className: question_nav_module_css_default.empty,
|
|
285
|
-
children:
|
|
250
|
+
children: t("strip.empty")
|
|
286
251
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
287
252
|
className: question_nav_module_css_default.dots,
|
|
288
|
-
children: [
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"aria-label": t("strip.loadEarlier"),
|
|
299
|
-
title: t("strip.loadEarlier"),
|
|
300
|
-
onMouseEnter: (e) => {
|
|
301
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
302
|
-
setTooltip({
|
|
303
|
-
text: t("strip.loadEarlier"),
|
|
304
|
-
left: r.right + 10,
|
|
305
|
-
top: r.top
|
|
306
|
-
});
|
|
307
|
-
},
|
|
308
|
-
onMouseLeave: () => setTooltip(null),
|
|
309
|
-
onClick: onLoadMore
|
|
310
|
-
}) : null,
|
|
311
|
-
questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
312
|
-
className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
|
|
313
|
-
"aria-label": node.text,
|
|
314
|
-
onMouseEnter: (e) => {
|
|
315
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
316
|
-
setTooltip({
|
|
317
|
-
text: node.text,
|
|
318
|
-
left: r.right + 10,
|
|
319
|
-
top: r.top
|
|
320
|
-
});
|
|
321
|
-
},
|
|
322
|
-
onMouseLeave: () => setTooltip(null),
|
|
323
|
-
onClick: () => onJump(node)
|
|
324
|
-
}, node.key))
|
|
325
|
-
]
|
|
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))]
|
|
326
263
|
})
|
|
327
264
|
}),
|
|
328
|
-
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", {
|
|
329
266
|
className: question_nav_module_css_default.tooltip,
|
|
330
267
|
style: {
|
|
331
268
|
left: tooltip.left,
|
|
332
269
|
top: tooltip.top
|
|
333
270
|
},
|
|
334
|
-
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))]
|
|
335
278
|
}), document.body) : null
|
|
336
279
|
]
|
|
337
280
|
});
|
|
@@ -344,9 +287,6 @@ window.__ModuleLoader__.load({
|
|
|
344
287
|
*/
|
|
345
288
|
const zh = {
|
|
346
289
|
"strip.empty": "本会话还没有提问",
|
|
347
|
-
"strip.loadingAll": "正在加载全部历史…",
|
|
348
|
-
"strip.loadingSuffix": "…",
|
|
349
|
-
"strip.loadEarlier": "加载更早的问题",
|
|
350
290
|
"jump.inactive": "聊天视图未激活",
|
|
351
291
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
352
292
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
@@ -354,15 +294,57 @@ window.__ModuleLoader__.load({
|
|
|
354
294
|
};
|
|
355
295
|
const en = {
|
|
356
296
|
"strip.empty": "No questions in this session yet",
|
|
357
|
-
"strip.loadingAll": "Loading full history…",
|
|
358
|
-
"strip.loadingSuffix": "…",
|
|
359
|
-
"strip.loadEarlier": "Load earlier questions",
|
|
360
297
|
"jump.inactive": "Chat view is not active",
|
|
361
298
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
362
299
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
363
300
|
"jump.timeout": "Timed out loading history; retry"
|
|
364
301
|
};
|
|
365
302
|
//#endregion
|
|
303
|
+
//#region src/core/nodes.ts
|
|
304
|
+
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
305
|
+
const QUESTION_KINDS = ["user", "steering"];
|
|
306
|
+
function userData(data) {
|
|
307
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
308
|
+
return data;
|
|
309
|
+
}
|
|
310
|
+
/** First text block of a user message; falls back to the raw first block. */
|
|
311
|
+
function messageText(content) {
|
|
312
|
+
if (content === void 0 || content.length === 0) return "";
|
|
313
|
+
const first = content[0];
|
|
314
|
+
if (typeof first?.text === "string") return first.text;
|
|
315
|
+
return "";
|
|
316
|
+
}
|
|
317
|
+
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
318
|
+
function extractQuestions(nodes) {
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const node of nodes) {
|
|
321
|
+
if (!QUESTION_KINDS.includes(node.kind)) continue;
|
|
322
|
+
const payload = userData(node.data);
|
|
323
|
+
out.push({
|
|
324
|
+
key: node.key,
|
|
325
|
+
anchorSeq: node.anchorSeq,
|
|
326
|
+
seq: payload?.seq ?? -1,
|
|
327
|
+
time: payload?.time ?? 0,
|
|
328
|
+
text: messageText(payload?.content)
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
out.sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
335
|
+
function nearestRenderable(nodes, excludeKey) {
|
|
336
|
+
let best = null;
|
|
337
|
+
for (const node of nodes) {
|
|
338
|
+
if (node.visibility === "hidden") continue;
|
|
339
|
+
if (node.key === excludeKey) continue;
|
|
340
|
+
if (best === null || node.anchorSeq < best.anchorSeq) best = {
|
|
341
|
+
key: node.key,
|
|
342
|
+
anchorSeq: node.anchorSeq
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return best;
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
366
348
|
//#region src/core/jump.ts
|
|
367
349
|
/**
|
|
368
350
|
* Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
|
|
@@ -370,7 +352,7 @@ window.__ModuleLoader__.load({
|
|
|
370
352
|
* paging/timeout/fallback loop is unit-testable without a real browser or
|
|
371
353
|
* session. The browser half wires these ports to ctx.sessions + the DOM.
|
|
372
354
|
*/
|
|
373
|
-
const DEFAULTS
|
|
355
|
+
const DEFAULTS = {
|
|
374
356
|
totalTimeoutMs: 15e3,
|
|
375
357
|
maxPages: 100,
|
|
376
358
|
rowWaitMs: 8e3,
|
|
@@ -399,7 +381,7 @@ window.__ModuleLoader__.load({
|
|
|
399
381
|
*/
|
|
400
382
|
async function jumpToQuestion(ports, key, options = {}) {
|
|
401
383
|
const cfg = {
|
|
402
|
-
...DEFAULTS
|
|
384
|
+
...DEFAULTS,
|
|
403
385
|
...options
|
|
404
386
|
};
|
|
405
387
|
const fail = (code, fallback = false) => {
|
|
@@ -465,133 +447,6 @@ window.__ModuleLoader__.load({
|
|
|
465
447
|
return fail("TARGET_HIDDEN", false);
|
|
466
448
|
}
|
|
467
449
|
//#endregion
|
|
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
|
-
}
|
|
502
|
-
const DEFAULTS = {
|
|
503
|
-
maxMessages: 100,
|
|
504
|
-
maxPages: 200,
|
|
505
|
-
totalTimeoutMs: 3e4
|
|
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
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Page the raw session history backward, collecting every user question into a
|
|
514
|
-
* lightweight index. Never touches the render window.
|
|
515
|
-
*/
|
|
516
|
-
async function buildQuestionIndex(ports, options = {}) {
|
|
517
|
-
const cfg = {
|
|
518
|
-
...DEFAULTS,
|
|
519
|
-
...options
|
|
520
|
-
};
|
|
521
|
-
const deadline = ports.now() + cfg.totalTimeoutMs;
|
|
522
|
-
const questions = [];
|
|
523
|
-
let beforeSeq = cfg.startBeforeSeq;
|
|
524
|
-
let pages = 0;
|
|
525
|
-
const cancelled = () => cfg.signal?.aborted === true;
|
|
526
|
-
while (true) {
|
|
527
|
-
if (cancelled()) return {
|
|
528
|
-
ok: false,
|
|
529
|
-
code: "CANCELLED",
|
|
530
|
-
questions,
|
|
531
|
-
pages,
|
|
532
|
-
nextBeforeSeq: beforeSeq
|
|
533
|
-
};
|
|
534
|
-
if (ports.now() > deadline) return {
|
|
535
|
-
ok: false,
|
|
536
|
-
code: "TIMEOUT",
|
|
537
|
-
questions,
|
|
538
|
-
pages,
|
|
539
|
-
nextBeforeSeq: beforeSeq
|
|
540
|
-
};
|
|
541
|
-
if (pages >= cfg.maxPages) return {
|
|
542
|
-
ok: false,
|
|
543
|
-
code: "BUDGET",
|
|
544
|
-
questions,
|
|
545
|
-
pages,
|
|
546
|
-
nextBeforeSeq: beforeSeq
|
|
547
|
-
};
|
|
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
|
-
};
|
|
564
|
-
}
|
|
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;
|
|
591
|
-
pages += 1;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
//#endregion
|
|
595
450
|
//#region src/client/index.ts
|
|
596
451
|
/** Locale namespace this plugin owns. */
|
|
597
452
|
const NS = "question-nav";
|
|
@@ -599,8 +454,7 @@ window.__ModuleLoader__.load({
|
|
|
599
454
|
const inject = [
|
|
600
455
|
"slots",
|
|
601
456
|
"locale",
|
|
602
|
-
"sessions"
|
|
603
|
-
"connection"
|
|
457
|
+
"sessions"
|
|
604
458
|
];
|
|
605
459
|
function claimApply() {
|
|
606
460
|
if (globalThis.__dshQuestionNavApplied === true) return false;
|
|
@@ -638,37 +492,19 @@ window.__ModuleLoader__.load({
|
|
|
638
492
|
sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
639
493
|
};
|
|
640
494
|
}
|
|
641
|
-
/** Resolve the connection handle (shared API client) as other DSH plugins do. */
|
|
642
|
-
function connectionOf(ctx) {
|
|
643
|
-
return ctx.get("connection");
|
|
644
|
-
}
|
|
645
495
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
|
|
650
|
-
* boundary (the index reader only touches type/seq/time/surfaceOp/data).
|
|
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.
|
|
651
499
|
*/
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
sessionId,
|
|
656
|
-
beforeSeq,
|
|
657
|
-
maxMessages
|
|
658
|
-
});
|
|
659
|
-
if (!result.ok) return void 0;
|
|
500
|
+
function questionProjectionOf(ctx, sessionId) {
|
|
501
|
+
const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf("questionIndex");
|
|
502
|
+
if (face === void 0) return void 0;
|
|
660
503
|
return {
|
|
661
|
-
|
|
662
|
-
|
|
504
|
+
getSnapshot: () => face.getSnapshot(),
|
|
505
|
+
subscribe: (listener) => face.subscribe(listener)
|
|
663
506
|
};
|
|
664
507
|
}
|
|
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);
|
|
671
|
-
}
|
|
672
508
|
function createInject(ctx) {
|
|
673
509
|
return {
|
|
674
510
|
readQuestions: (sessionId) => {
|
|
@@ -682,14 +518,14 @@ window.__ModuleLoader__.load({
|
|
|
682
518
|
if (binding === void 0) return () => {};
|
|
683
519
|
return binding.session.subscribe(cb);
|
|
684
520
|
},
|
|
521
|
+
questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),
|
|
685
522
|
jump: (sessionId, key) => {
|
|
686
523
|
const ports = jumpPortsFor(ctx, sessionId);
|
|
687
524
|
ports.report = (code) => {
|
|
688
525
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
689
526
|
};
|
|
690
527
|
jumpToQuestion(ports, key);
|
|
691
|
-
}
|
|
692
|
-
fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options)
|
|
528
|
+
}
|
|
693
529
|
};
|
|
694
530
|
}
|
|
695
531
|
/**
|