@luziyang2026/dsh-question-nav 0.2.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/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,8 +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/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)}`;
20
+ }
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]
47
+ });
48
+ }
49
+ return dots;
50
+ }
51
+ /**
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.
57
+ */
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);
70
+ }
71
+ //#endregion
10
72
  //#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_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}";
12
74
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
13
75
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
76
  const tag = document.createElement("style");
@@ -20,35 +82,37 @@ window.__ModuleLoader__.load({
20
82
  var question_nav_module_css_default = {
21
83
  "active": "TWf_pa_active",
22
84
  "count": "TWf_pa_count",
23
- "countLoading": "TWf_pa_countLoading",
24
85
  "dot": "TWf_pa_dot",
25
86
  "dots": "TWf_pa_dots",
26
87
  "empty": "TWf_pa_empty",
27
88
  "list": "TWf_pa_list",
28
- "moreDot": "TWf_pa_moreDot",
29
- "qnPulse": "TWf_pa_qnPulse",
30
89
  "rail": "TWf_pa_rail",
31
- "tooltip": "TWf_pa_tooltip"
90
+ "tooltip": "TWf_pa_tooltip",
91
+ "tooltipLine": "TWf_pa_tooltipLine",
92
+ "tooltipTitle": "TWf_pa_tooltipTitle"
32
93
  };
33
94
  //#endregion
34
95
  //#region src/client/QuestionNavStrip.tsx
35
96
  /**
36
97
  * Question-nav minimap. Renders a vertical column of small round dots overlaid
37
98
  * on the LEFT edge of the conversation column (via the frame-wide
38
- * `shell.overlay` floating layer), vertically centered: one dot per user
39
- * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
40
- * no native-title delay) shows the question's full text; clicking a dot scrolls
41
- * 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.
42
105
  *
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.
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.
48
112
  *
49
- * Data arrives through the four props shares: the framework `useSessions`
50
- * hook (current session), the registrant inject face (read/subscribe/jump/
51
- * load-all), 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.
52
116
  */
53
117
  const FAILURE_HINTS = {
54
118
  VIEW_INACTIVE: "jump.inactive",
@@ -56,6 +120,12 @@ window.__ModuleLoader__.load({
56
120
  NOT_FOUND: "jump.notfound",
57
121
  TIMEOUT: "jump.timeout"
58
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
+ }
59
129
  function findConvRoot() {
60
130
  return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
61
131
  }
@@ -63,18 +133,12 @@ window.__ModuleLoader__.load({
63
133
  const current = props.useSessions((s) => s.current);
64
134
  const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
65
135
  const visible = current !== void 0 && summary !== void 0 && summary.blank !== true;
66
- const [questions, setQuestions] = (0, react.useState)([]);
136
+ const [dots, setDots] = (0, react.useState)([]);
67
137
  const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
68
138
  const [hint, setHint] = (0, react.useState)(null);
69
139
  const [tooltip, setTooltip] = (0, react.useState)(null);
70
- const [loadingAll, setLoadingAll] = (0, react.useState)(false);
71
- const [moreAvailable, setMoreAvailable] = (0, react.useState)(false);
72
140
  const panelRef = (0, react.useRef)(null);
73
141
  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);
78
142
  const showHint = (message) => {
79
143
  setHint(message);
80
144
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
@@ -82,14 +146,21 @@ window.__ModuleLoader__.load({
82
146
  };
83
147
  (0, react.useEffect)(() => {
84
148
  if (!visible || current === void 0) {
85
- setQuestions([]);
149
+ setDots([]);
86
150
  return;
87
151
  }
88
- const refresh = () => setQuestions(props.readQuestions(current));
152
+ const sessionId = current;
153
+ const face = props.questionProjection(sessionId);
154
+ const refresh = () => {
155
+ const grouped = groupQuestionsByTurn(projectionEntries(face));
156
+ setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
157
+ };
89
158
  refresh();
90
- const unsubContent = props.subscribeContent(current, refresh);
159
+ const unsubProjection = face?.subscribe(refresh) ?? (() => {});
160
+ const unsubContent = props.subscribeContent(sessionId, refresh);
91
161
  const unsubList = props.subscribeList(refresh);
92
162
  return () => {
163
+ unsubProjection();
93
164
  unsubContent();
94
165
  unsubList();
95
166
  };
@@ -98,36 +169,6 @@ window.__ModuleLoader__.load({
98
169
  current,
99
170
  props
100
171
  ]);
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
172
  (0, react.useEffect)(() => {
132
173
  const onJumpFailed = (event) => {
133
174
  const code = event.detail;
@@ -176,19 +217,20 @@ window.__ModuleLoader__.load({
176
217
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
177
218
  }, []);
178
219
  if (!visible) return null;
179
- const onJump = (node) => {
220
+ const onJump = (dot) => {
180
221
  if (current === void 0) return;
181
- setJumpingKey(node.key);
182
- props.jump(current, node.key);
183
- 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);
184
225
  };
185
- const onLoadMore = () => {
186
- if (current === void 0) return;
187
- setMoreAvailable(false);
188
- setLoadingAll(true);
189
- props.loadAllOlder(current).then((result) => {
190
- setMoreAvailable(result.code === "BUDGET" && !result.ok);
191
- }).finally(() => setLoadingAll(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
+ });
192
234
  };
193
235
  const t = props.t;
194
236
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -203,58 +245,36 @@ window.__ModuleLoader__.load({
203
245
  }) : null,
204
246
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
205
247
  className: question_nav_module_css_default.list,
206
- children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
248
+ children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
207
249
  className: question_nav_module_css_default.empty,
208
- children: loadingAll ? t("strip.loadingAll") : t("strip.empty")
250
+ children: t("strip.empty")
209
251
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
210
252
  className: question_nav_module_css_default.dots,
211
- children: [
212
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
213
- className: question_nav_module_css_default.count,
214
- children: [questions.length, loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
215
- className: question_nav_module_css_default.countLoading,
216
- children: t("strip.loadingSuffix")
217
- }) : null]
218
- }),
219
- moreAvailable && !loadingAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
220
- className: `${question_nav_module_css_default.dot} ${question_nav_module_css_default.moreDot}`,
221
- "aria-label": t("strip.loadEarlier"),
222
- title: t("strip.loadEarlier"),
223
- onMouseEnter: (e) => {
224
- const r = e.currentTarget.getBoundingClientRect();
225
- setTooltip({
226
- text: t("strip.loadEarlier"),
227
- left: r.right + 10,
228
- top: r.top
229
- });
230
- },
231
- onMouseLeave: () => setTooltip(null),
232
- onClick: onLoadMore
233
- }) : null,
234
- questions.map((node) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
235
- className: jumpingKey === node.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
236
- "aria-label": node.text,
237
- onMouseEnter: (e) => {
238
- const r = e.currentTarget.getBoundingClientRect();
239
- setTooltip({
240
- text: node.text,
241
- left: r.right + 10,
242
- top: r.top
243
- });
244
- },
245
- onMouseLeave: () => setTooltip(null),
246
- onClick: () => onJump(node)
247
- }, node.key))
248
- ]
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))]
249
263
  })
250
264
  }),
251
- 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", {
252
266
  className: question_nav_module_css_default.tooltip,
253
267
  style: {
254
268
  left: tooltip.left,
255
269
  top: tooltip.top
256
270
  },
257
- 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))]
258
278
  }), document.body) : null
259
279
  ]
260
280
  });
@@ -267,9 +287,6 @@ window.__ModuleLoader__.load({
267
287
  */
268
288
  const zh = {
269
289
  "strip.empty": "本会话还没有提问",
270
- "strip.loadingAll": "正在加载全部历史…",
271
- "strip.loadingSuffix": "…",
272
- "strip.loadEarlier": "加载更早的问题",
273
290
  "jump.inactive": "聊天视图未激活",
274
291
  "jump.hidden": "目标无独立气泡,已定位到邻近内容",
275
292
  "jump.notfound": "目标未加载或不存在(可能已压缩)",
@@ -277,9 +294,6 @@ window.__ModuleLoader__.load({
277
294
  };
278
295
  const en = {
279
296
  "strip.empty": "No questions in this session yet",
280
- "strip.loadingAll": "Loading full history…",
281
- "strip.loadingSuffix": "…",
282
- "strip.loadEarlier": "Load earlier questions",
283
297
  "jump.inactive": "Chat view is not active",
284
298
  "jump.hidden": "No dedicated bubble; landed on nearby content",
285
299
  "jump.notfound": "Target not loaded or missing (maybe compacted)",
@@ -338,7 +352,7 @@ window.__ModuleLoader__.load({
338
352
  * paging/timeout/fallback loop is unit-testable without a real browser or
339
353
  * session. The browser half wires these ports to ctx.sessions + the DOM.
340
354
  */
341
- const DEFAULTS$1 = {
355
+ const DEFAULTS = {
342
356
  totalTimeoutMs: 15e3,
343
357
  maxPages: 100,
344
358
  rowWaitMs: 8e3,
@@ -367,7 +381,7 @@ window.__ModuleLoader__.load({
367
381
  */
368
382
  async function jumpToQuestion(ports, key, options = {}) {
369
383
  const cfg = {
370
- ...DEFAULTS$1,
384
+ ...DEFAULTS,
371
385
  ...options
372
386
  };
373
387
  const fail = (code, fallback = false) => {
@@ -433,72 +447,6 @@ window.__ModuleLoader__.load({
433
447
  return fail("TARGET_HIDDEN", false);
434
448
  }
435
449
  //#endregion
436
- //#region src/core/load-all.ts
437
- const DEFAULTS = {
438
- maxPages: 400,
439
- totalTimeoutMs: 6e4,
440
- pollMs: 60
441
- };
442
- /**
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`.
447
- */
448
- async function loadAllOlder(ports, options = {}) {
449
- const cfg = {
450
- ...DEFAULTS,
451
- ...options
452
- };
453
- const deadline = ports.now() + cfg.totalTimeoutMs;
454
- let pages = 0;
455
- const cancelled = () => cfg.signal?.aborted === true;
456
- while (true) {
457
- if (cancelled()) return {
458
- ok: false,
459
- 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
472
- };
473
- if (snap.openState === "error") return {
474
- ok: false,
475
- code: "NOT_OPEN",
476
- pages
477
- };
478
- if (snap.hasMore !== true) return {
479
- ok: true,
480
- code: "COMPLETE",
481
- pages
482
- };
483
- if (pages >= cfg.maxPages) return {
484
- ok: false,
485
- code: "BUDGET",
486
- pages
487
- };
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;
496
- }
497
- await ports.loadOlder();
498
- pages += 1;
499
- }
500
- }
501
- //#endregion
502
450
  //#region src/client/index.ts
503
451
  /** Locale namespace this plugin owns. */
504
452
  const NS = "question-nav";
@@ -544,50 +492,19 @@ window.__ModuleLoader__.load({
544
492
  sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
545
493
  };
546
494
  }
547
- /** Resolve the active conversation scrollport (or null when not mounted). */
548
- function scrollport() {
549
- return document.querySelector("[data-conversation-scroll]");
550
- }
551
495
  /**
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.
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.
556
499
  */
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) {
500
+ function questionProjectionOf(ctx, sessionId) {
501
+ const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf("questionIndex");
502
+ if (face === void 0) return void 0;
571
503
  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))
504
+ getSnapshot: () => face.getSnapshot(),
505
+ subscribe: (listener) => face.subscribe(listener)
585
506
  };
586
507
  }
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);
590
- }
591
508
  function createInject(ctx) {
592
509
  return {
593
510
  readQuestions: (sessionId) => {
@@ -601,14 +518,14 @@ window.__ModuleLoader__.load({
601
518
  if (binding === void 0) return () => {};
602
519
  return binding.session.subscribe(cb);
603
520
  },
521
+ questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),
604
522
  jump: (sessionId, key) => {
605
523
  const ports = jumpPortsFor(ctx, sessionId);
606
524
  ports.report = (code) => {
607
525
  window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
608
526
  };
609
527
  jumpToQuestion(ports, key);
610
- },
611
- loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options)
528
+ }
612
529
  };
613
530
  }
614
531
  /**