@luziyang2026/dsh-question-nav 0.1.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/lib/client.js CHANGED
@@ -7,8 +7,64 @@ 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
- 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}";
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";
13
69
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
70
  const tag = document.createElement("style");
@@ -20,10 +76,13 @@ window.__ModuleLoader__.load({
20
76
  var question_nav_module_css_default = {
21
77
  "active": "TWf_pa_active",
22
78
  "count": "TWf_pa_count",
79
+ "countLoading": "TWf_pa_countLoading",
23
80
  "dot": "TWf_pa_dot",
24
81
  "dots": "TWf_pa_dots",
25
82
  "empty": "TWf_pa_empty",
26
83
  "list": "TWf_pa_list",
84
+ "moreDot": "TWf_pa_moreDot",
85
+ "qnPulse": "TWf_pa_qnPulse",
27
86
  "rail": "TWf_pa_rail",
28
87
  "tooltip": "TWf_pa_tooltip"
29
88
  };
@@ -37,9 +96,19 @@ window.__ModuleLoader__.load({
37
96
  * no native-title delay) shows the question's full text; clicking a dot scrolls
38
97
  * the chat to that question.
39
98
  *
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.
108
+ *
40
109
  * Data arrives through the four props shares: the framework `useSessions`
41
- * hook (current session), the registrant inject face (read/subscribe/jump),
42
- * and the bound locale translator.
110
+ * hook (current session), the registrant inject face (read/subscribe/jump/
111
+ * fetch-index), and the bound locale translator.
43
112
  */
44
113
  const FAILURE_HINTS = {
45
114
  VIEW_INACTIVE: "jump.inactive",
@@ -58,8 +127,18 @@ window.__ModuleLoader__.load({
58
127
  const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
59
128
  const [hint, setHint] = (0, react.useState)(null);
60
129
  const [tooltip, setTooltip] = (0, react.useState)(null);
130
+ const [loadingIndex, setLoadingIndex] = (0, react.useState)(false);
131
+ const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
61
132
  const panelRef = (0, react.useRef)(null);
62
133
  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);
63
142
  const showHint = (message) => {
64
143
  setHint(message);
65
144
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
@@ -67,14 +146,53 @@ window.__ModuleLoader__.load({
67
146
  };
68
147
  (0, react.useEffect)(() => {
69
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;
70
154
  setQuestions([]);
155
+ setLoadingIndex(false);
156
+ setMoreAvailable(false);
71
157
  return;
72
158
  }
73
- const refresh = () => setQuestions(props.readQuestions(current));
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
+ };
74
189
  refresh();
75
- const unsubContent = props.subscribeContent(current, refresh);
190
+ startBuild();
191
+ const unsubContent = props.subscribeContent(sessionId, refresh);
76
192
  const unsubList = props.subscribeList(refresh);
77
193
  return () => {
194
+ indexAbortRef.current?.abort();
195
+ indexAbortRef.current = null;
78
196
  unsubContent();
79
197
  unsubList();
80
198
  };
@@ -137,6 +255,18 @@ window.__ModuleLoader__.load({
137
255
  props.jump(current, node.key);
138
256
  window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
139
257
  };
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));
269
+ };
140
270
  const t = props.t;
141
271
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
142
272
  ref: panelRef,
@@ -152,26 +282,47 @@ window.__ModuleLoader__.load({
152
282
  className: question_nav_module_css_default.list,
153
283
  children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
154
284
  className: question_nav_module_css_default.empty,
155
- children: t("strip.empty")
285
+ children: loadingIndex ? t("strip.loadingAll") : t("strip.empty")
156
286
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
157
287
  className: question_nav_module_css_default.dots,
158
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
159
- className: question_nav_module_css_default.count,
160
- children: questions.length
161
- }), questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
162
- className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
163
- "aria-label": node.text,
164
- onMouseEnter: (e) => {
165
- const r = e.currentTarget.getBoundingClientRect();
166
- setTooltip({
167
- text: node.text,
168
- left: r.right + 10,
169
- top: r.top
170
- });
171
- },
172
- onMouseLeave: () => setTooltip(null),
173
- onClick: () => onJump(node)
174
- }, node.key))]
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
+ ]
175
326
  })
176
327
  }),
177
328
  tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -193,6 +344,9 @@ window.__ModuleLoader__.load({
193
344
  */
194
345
  const zh = {
195
346
  "strip.empty": "本会话还没有提问",
347
+ "strip.loadingAll": "正在加载全部历史…",
348
+ "strip.loadingSuffix": "…",
349
+ "strip.loadEarlier": "加载更早的问题",
196
350
  "jump.inactive": "聊天视图未激活",
197
351
  "jump.hidden": "目标无独立气泡,已定位到邻近内容",
198
352
  "jump.notfound": "目标未加载或不存在(可能已压缩)",
@@ -200,57 +354,15 @@ window.__ModuleLoader__.load({
200
354
  };
201
355
  const en = {
202
356
  "strip.empty": "No questions in this session yet",
357
+ "strip.loadingAll": "Loading full history…",
358
+ "strip.loadingSuffix": "…",
359
+ "strip.loadEarlier": "Load earlier questions",
203
360
  "jump.inactive": "Chat view is not active",
204
361
  "jump.hidden": "No dedicated bubble; landed on nearby content",
205
362
  "jump.notfound": "Target not loaded or missing (maybe compacted)",
206
363
  "jump.timeout": "Timed out loading history; retry"
207
364
  };
208
365
  //#endregion
209
- //#region src/core/nodes.ts
210
- /** Kinds counted as a user question (turn-opening and steering admissions). */
211
- const QUESTION_KINDS = ["user", "steering"];
212
- function userData(data) {
213
- if (typeof data !== "object" || data === null) return void 0;
214
- return data;
215
- }
216
- /** First text block of a user message; falls back to the raw first block. */
217
- function messageText(content) {
218
- if (content === void 0 || content.length === 0) return "";
219
- const first = content[0];
220
- if (typeof first?.text === "string") return first.text;
221
- return "";
222
- }
223
- /** Extract the user questions from a chat-node window, ordered by anchorSeq. */
224
- function extractQuestions(nodes) {
225
- const out = [];
226
- for (const node of nodes) {
227
- if (!QUESTION_KINDS.includes(node.kind)) continue;
228
- const payload = userData(node.data);
229
- out.push({
230
- key: node.key,
231
- anchorSeq: node.anchorSeq,
232
- seq: payload?.seq ?? -1,
233
- time: payload?.time ?? 0,
234
- text: messageText(payload?.content)
235
- });
236
- }
237
- out.sort((a, b) => a.anchorSeq - b.anchorSeq);
238
- return out;
239
- }
240
- /** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
241
- function nearestRenderable(nodes, excludeKey) {
242
- let best = null;
243
- for (const node of nodes) {
244
- if (node.visibility === "hidden") continue;
245
- if (node.key === excludeKey) continue;
246
- if (best === null || node.anchorSeq < best.anchorSeq) best = {
247
- key: node.key,
248
- anchorSeq: node.anchorSeq
249
- };
250
- }
251
- return best;
252
- }
253
- //#endregion
254
366
  //#region src/core/jump.ts
255
367
  /**
256
368
  * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
@@ -258,7 +370,7 @@ window.__ModuleLoader__.load({
258
370
  * paging/timeout/fallback loop is unit-testable without a real browser or
259
371
  * session. The browser half wires these ports to ctx.sessions + the DOM.
260
372
  */
261
- const DEFAULTS = {
373
+ const DEFAULTS$1 = {
262
374
  totalTimeoutMs: 15e3,
263
375
  maxPages: 100,
264
376
  rowWaitMs: 8e3,
@@ -287,7 +399,7 @@ window.__ModuleLoader__.load({
287
399
  */
288
400
  async function jumpToQuestion(ports, key, options = {}) {
289
401
  const cfg = {
290
- ...DEFAULTS,
402
+ ...DEFAULTS$1,
291
403
  ...options
292
404
  };
293
405
  const fail = (code, fallback = false) => {
@@ -353,6 +465,133 @@ window.__ModuleLoader__.load({
353
465
  return fail("TARGET_HIDDEN", false);
354
466
  }
355
467
  //#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
356
595
  //#region src/client/index.ts
357
596
  /** Locale namespace this plugin owns. */
358
597
  const NS = "question-nav";
@@ -360,7 +599,8 @@ window.__ModuleLoader__.load({
360
599
  const inject = [
361
600
  "slots",
362
601
  "locale",
363
- "sessions"
602
+ "sessions",
603
+ "connection"
364
604
  ];
365
605
  function claimApply() {
366
606
  if (globalThis.__dshQuestionNavApplied === true) return false;
@@ -398,6 +638,37 @@ window.__ModuleLoader__.load({
398
638
  sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
399
639
  };
400
640
  }
641
+ /** Resolve the connection handle (shared API client) as other DSH plugins do. */
642
+ function connectionOf(ctx) {
643
+ return ctx.get("connection");
644
+ }
645
+ /**
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).
651
+ */
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;
660
+ return {
661
+ events: result.value.events.map((entry) => ({ event: entry.event })),
662
+ hasMore: result.value.hasMore
663
+ };
664
+ }
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
+ }
401
672
  function createInject(ctx) {
402
673
  return {
403
674
  readQuestions: (sessionId) => {
@@ -417,7 +688,8 @@ window.__ModuleLoader__.load({
417
688
  window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
418
689
  };
419
690
  jumpToQuestion(ports, key);
420
- }
691
+ },
692
+ fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options)
421
693
  };
422
694
  }
423
695
  /**