@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 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/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 "";
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
- /** 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)
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
- 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;
49
+ return dots;
53
50
  }
54
51
  /**
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.
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 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);
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)}.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}";
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 user
95
- * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
96
- * no native-title delay) shows the question's full text; clicking a dot scrolls
97
- * the chat to that question.
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
- * 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.
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 four props shares: the framework `useSessions`
110
- * hook (current session), the registrant inject face (read/subscribe/jump/
111
- * fetch-index), and the bound locale translator.
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 [questions, setQuestions] = (0, react.useState)([]);
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
- indexRef.current = [];
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
- indexRef.current = [];
161
- nextBeforeSeqRef.current = void 0;
153
+ const face = props.questionProjection(sessionId);
162
154
  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
- });
155
+ const grouped = groupQuestionsByTurn(projectionEntries(face));
156
+ setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
188
157
  };
189
158
  refresh();
190
- startBuild();
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
- indexAbortRef.current?.abort();
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 = (node) => {
220
+ const onJump = (dot) => {
253
221
  if (current === void 0) return;
254
- setJumpingKey(node.key);
255
- props.jump(current, node.key);
256
- window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
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 onLoadMore = () => {
259
- if (current === void 0 || nextBeforeSeqRef.current === void 0) return;
260
- setMoreAvailable(false);
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));
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: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
248
+ children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
284
249
  className: question_nav_module_css_default.empty,
285
- children: loadingIndex ? t("strip.loadingAll") : t("strip.empty")
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
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
290
- className: question_nav_module_css_default.count,
291
- children: [questions.length, loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
292
- className: question_nav_module_css_default.countLoading,
293
- children: t("strip.loadingSuffix")
294
- }) : null]
295
- }),
296
- moreAvailable && !loadingIndex ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
297
- className: `${question_nav_module_css_default.dot} ${question_nav_module_css_default.moreDot}`,
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.jsx)("div", {
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.text
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$1 = {
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$1,
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
- * 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).
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
- 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;
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
- events: result.value.events.map((entry) => ({ event: entry.event })),
662
- hasMore: result.value.hasMore
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
  /**