@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 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**: on show, the strip auto-expands the session's older pages
21
- (paging DSH's own "load older" window) so even questions that are still
22
- collapsed behind the load-more button surface as dots. While expanding, the
23
- count shows a "…" affordance; if the safety budget is exhausted a dimmed
24
- dashed dot above the oldest question offers "load earlier" on click.
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, paging older history when the target is
28
- not yet in the loaded window (with a nearest-row fallback).
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
- - **点击**:跳转到该提问,目标尚未加载时自动 `loadOlder` 扩窗(并落到最近可见行兜底)。
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
- * Dots index the WHOLE session history, not just the currently loaded window:
44
- * on show, the strip auto-expands older pages (`loadAllOlder`) so questions
45
- * that still sit behind DSH's "load older" button are surfaced too. While the
46
- * expansion is running the count shows a "…" affordance; if the safety budget
47
- * is exhausted a dimmed "load earlier" dot appears above the oldest question.
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
- * load-all), and the bound locale translator.
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 [loadingAll, setLoadingAll] = (0, react.useState)(false);
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
- /** Abort controller for the in-flight expansion (cancelled on session change). */
75
- const loadAllAbortRef = (0, react.useRef)(null);
76
- /** Session whose expansion is already running, to avoid duplicate loops. */
77
- const loadingAllSessionRef = (0, react.useRef)(null);
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 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
+ };
89
189
  refresh();
90
- const unsubContent = props.subscribeContent(current, refresh);
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
- setLoadingAll(true);
189
- props.loadAllOlder(current).then((result) => {
190
- setMoreAvailable(result.code === "BUDGET" && !result.ok);
191
- }).finally(() => setLoadingAll(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));
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: loadingAll ? t("strip.loadingAll") : t("strip.empty")
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, loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
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 && !loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
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/load-all.ts
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
- maxPages: 400,
439
- totalTimeoutMs: 6e4,
440
- pollMs: 60
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
- * Expand the session window backwards until the earliest history is loaded.
444
- * Waits while the session is still opening; aborts on cancellation, budget or
445
- * timeout. Safe to re-enter: once `hasMore` is false the loop returns
446
- * immediately with `COMPLETE`.
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 loadAllOlder(ports, options = {}) {
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
- pages
461
- };
462
- if (!ports.isViewActive()) return {
463
- ok: false,
464
- code: "VIEW_INACTIVE",
465
- pages
466
- };
467
- const snap = ports.snapshot();
468
- if (snap === void 0) return {
469
- ok: false,
470
- code: "VIEW_INACTIVE",
471
- pages
530
+ questions,
531
+ pages,
532
+ nextBeforeSeq: beforeSeq
472
533
  };
473
- if (snap.openState === "error") return {
534
+ if (ports.now() > deadline) return {
474
535
  ok: false,
475
- code: "NOT_OPEN",
476
- pages
477
- };
478
- if (snap.hasMore !== true) return {
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
- pages
544
+ questions,
545
+ pages,
546
+ nextBeforeSeq: beforeSeq
487
547
  };
488
- if (ports.now() > deadline) return {
489
- ok: false,
490
- code: "TIMEOUT",
491
- pages
492
- };
493
- if (snap.openState !== "open" || snap.loadingOlder) {
494
- await ports.sleep(cfg.pollMs);
495
- continue;
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
- await ports.loadOlder();
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 active conversation scrollport (or null when not mounted). */
548
- function scrollport() {
549
- return document.querySelector("[data-conversation-scroll]");
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 backward page that preserves the reader's scroll position. DSH's own
553
- * "load older" button arms a paging anchor; a programmatic `loadOlder()` does
554
- * not, so without this compensation prepended content would push the visible
555
- * rows down. We restore by the exact growth of the scrollHeight.
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 pagedLoadOlder(ctx, sessionId) {
558
- const binding = ctx.sessions.binding(sessionId);
559
- if (binding === void 0) return;
560
- const port = scrollport();
561
- const beforeHeight = port?.scrollHeight ?? 0;
562
- const beforeTop = port?.scrollTop ?? 0;
563
- await binding.session.loadOlder();
564
- if (port === null) return;
565
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
566
- const delta = port.scrollHeight - beforeHeight;
567
- if (delta > 0) port.scrollTop = beforeTop + delta;
568
- }
569
- /** Map the session to the load-all port surface. */
570
- function loadAllPortsFor(ctx, sessionId) {
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
- snapshot: () => {
573
- const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
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
- /** Expand the whole session history so every question becomes a dot. */
588
- function loadAllFor(ctx, sessionId, options = {}) {
589
- return loadAllOlder(loadAllPortsFor(ctx, sessionId), options);
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
- loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options)
692
+ fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options)
612
693
  };
613
694
  }
614
695
  /**