@luziyang2026/dsh-question-nav 0.1.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/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Abel Keith Sun. All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,
9
+ this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from this
17
+ software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # dsh-question-nav
2
+
3
+ In-session question navigator for the [DeepSeek Harness (DSH) Web GUI][dsh]: a
4
+ vertical column of small round dots overlaid on the **left edge** of the
5
+ conversation column — one dot per user question. Hover a dot to enlarge it and
6
+ see the question's **full text** immediately; click a dot to scroll the chat to
7
+ that question.
8
+
9
+ It is an external plugin bundle with zero runtime dependencies — the browser
10
+ half (`lib/client.js`) externalizes everything to the DSH shell, so it adds
11
+ only a small bundle to the GUI at load time.
12
+
13
+ ## What it does
14
+
15
+ - **Left-edge dot minimap** (embedded, not reserving any width).
16
+ - **Vertically centered** in the conversation column.
17
+ - **One dot = one user question**, with a small count above the dot column.
18
+ - **Hover**: the dot enlarges and an instant tooltip (portal-rendered, no
19
+ native-title delay) shows the question's **full text**.
20
+ - **Click**: jumps to that question, paging older history when the target is
21
+ not yet in the loaded window (with a nearest-row fallback).
22
+ - Empty/left areas of the rail pass pointer events through to the conversation
23
+ (it never blocks the chat).
24
+
25
+ ## Behavior notes
26
+
27
+ - Only the current session is indexed; the question list reflects the loaded
28
+ chat window and expands as history is paged in.
29
+ - `user` and `steering` user messages count as questions.
30
+
31
+ ## Install (development / local checkout)
32
+
33
+ This repository is a standalone plugin project — build it, then add it to a
34
+ profile as an installable [bundle][bundle].
35
+
36
+ ```sh
37
+ pnpm install # install devDependencies (DSH SDK peers, tsdown, vitest)
38
+ pnpm build # tsc declarations + tsdown client bundle -> lib/
39
+ ```
40
+
41
+ Add it to a profile (the `dsh` CLI is required):
42
+
43
+ ```sh
44
+ dsh plugin --profile web add ./dsh-question-nav
45
+ ```
46
+
47
+ Or, once published, install the prebuilt package from npm:
48
+
49
+ ```sh
50
+ dsh plugin --profile web add @luziyang2026/dsh-question-nav
51
+ ```
52
+
53
+ Restart the DSH Web GUI to load the new bundle.
54
+
55
+ ## Development
56
+
57
+ ```sh
58
+ pnpm typecheck # tsc --noEmit
59
+ pnpm test # vitest run
60
+ pnpm build # build host lib/index.js + client lib/client.js
61
+ ```
62
+
63
+ ## How it is built
64
+
65
+ The client half uses the shared DSH client-bundle preset
66
+ ([`tsdown.client.ts`](tsdown.client.ts), vendored into this repo), which emits
67
+ a `window.__ModuleLoader__.load({ id, factory })` closure-factory
68
+ artifact with CSS Modules inlined and externals resolved through the loader
69
+ module table.
70
+
71
+ ## License
72
+
73
+ BSD-3-Clause.
74
+
75
+ [dsh]: https://github.com/deepseek-harness/deepseek-harness
76
+ [bundle]: https://github.com/deepseek-harness/deepseek-harness
package/README.zh.md ADDED
@@ -0,0 +1,67 @@
1
+ # dsh-question-nav
2
+
3
+ [DeepSeek Harness (DSH) Web GUI][dsh] 的**会话内提问导航**插件:在对话栏**左侧**
4
+ 内嵌一列竖排的圆点小按钮 —— 每个圆点对应一个用户提问。鼠标悬停圆点会放大
5
+ 并**立即**显示该提问的**全文**;点击圆点则把对话滚动跳到那一提问的位置。
6
+
7
+ 它是外部插件 bundle,**零运行时依赖** —— 浏览器半区(`lib/client.js`)把一切
8
+ 外部化给 DSH 外壳,加载时只往 GUI 里加一个很小的 bundle。
9
+
10
+ ## 功能
11
+
12
+ - **左缘圆点迷你地图**:内嵌,**不占任何宽度**。
13
+ - **垂直居中**在对话栏中。
14
+ - **一个圆点 = 一个用户提问**,圆点列上方有提问数量。
15
+ - **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示**全文**。
16
+ - **点击**:跳转到该提问,目标尚未加载时自动 `loadOlder` 扩窗(并落到最近可见行兜底)。
17
+ - 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
18
+
19
+ ## 行为说明
20
+
21
+ - 只索引当前会话;问题列表反映已加载的对话窗口,随历史扩窗补全。
22
+ - `user` 与 `steering` 用户消息计为提问。
23
+
24
+ ## 安装(开发 / 本地 checkout)
25
+
26
+ 本仓库是独立插件工程 —— 先构建,再作为可安装的 [bundle][bundle] 加进 profile。
27
+
28
+ ```sh
29
+ pnpm install # 安装 devDependencies(DSH SDK peers、tsdown、vitest)
30
+ pnpm build # tsc 声明 + tsdown client bundle -> lib/
31
+ ```
32
+
33
+ 加进 profile(需要 `dsh` CLI):
34
+
35
+ ```sh
36
+ dsh plugin --profile web add ./dsh-question-nav
37
+ ```
38
+
39
+ 发布后也可直接从 npm 装预构建产物:
40
+
41
+ ```sh
42
+ dsh plugin --profile web add @luziyang2026/dsh-question-nav
43
+ ```
44
+
45
+ 重启 DSH Web GUI 以加载新 bundle。
46
+
47
+ ## 开发
48
+
49
+ ```sh
50
+ pnpm typecheck # tsc --noEmit
51
+ pnpm test # vitest run
52
+ pnpm build # 构建 host lib/index.js + client lib/client.js
53
+ ```
54
+
55
+ ## 构建方式
56
+
57
+ client 半区使用共享的 DSH client-bundle 预设([`tsdown.client.ts`](tsdown.client.ts),
58
+ vendored 到本项目内),产出
59
+ `window.__ModuleLoader__.load({ id, factory })` 闭包工厂产物,CSS Modules 内联,
60
+ externals 经加载器模块表解析。
61
+
62
+ ## License
63
+
64
+ BSD-3-Clause。
65
+
66
+ [dsh]: https://github.com/deepseek-harness/deepseek-harness
67
+ [bundle]: https://github.com/deepseek-harness/deepseek-harness
@@ -0,0 +1,8 @@
1
+ # dsh-question-nav bundle patch: inserts its plugin row into the web profile
2
+ # roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
3
+ # field); install with
4
+ # `dsh plugin --profile web add ./dsh-question-nav` (from a local checkout) or
5
+ # `dsh plugin --profile web add <npm-package>` once published.
6
+ - insert:
7
+ - id: question-nav
8
+ name: '@luziyang2026/dsh-question-nav'
package/lib/client.js ADDED
@@ -0,0 +1,450 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@luziyang2026/dsh-question-nav",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_dom = require("react-dom");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
+ //#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}";
12
+ const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
13
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
14
+ const tag = document.createElement("style");
15
+ tag.dataset.plugin = "@luziyang2026/dsh-question-nav";
16
+ tag.dataset.pluginCss = tagId;
17
+ tag.textContent = css;
18
+ document.head.appendChild(tag);
19
+ }
20
+ var question_nav_module_css_default = {
21
+ "active": "TWf_pa_active",
22
+ "count": "TWf_pa_count",
23
+ "dot": "TWf_pa_dot",
24
+ "dots": "TWf_pa_dots",
25
+ "empty": "TWf_pa_empty",
26
+ "list": "TWf_pa_list",
27
+ "rail": "TWf_pa_rail",
28
+ "tooltip": "TWf_pa_tooltip"
29
+ };
30
+ //#endregion
31
+ //#region src/client/QuestionNavStrip.tsx
32
+ /**
33
+ * Question-nav minimap. Renders a vertical column of small round dots overlaid
34
+ * on the LEFT edge of the conversation column (via the frame-wide
35
+ * `shell.overlay` floating layer), vertically centered: one dot per user
36
+ * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
37
+ * no native-title delay) shows the question's full text; clicking a dot scrolls
38
+ * the chat to that question.
39
+ *
40
+ * 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.
43
+ */
44
+ const FAILURE_HINTS = {
45
+ VIEW_INACTIVE: "jump.inactive",
46
+ TARGET_HIDDEN: "jump.hidden",
47
+ NOT_FOUND: "jump.notfound",
48
+ TIMEOUT: "jump.timeout"
49
+ };
50
+ function findConvRoot() {
51
+ return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
52
+ }
53
+ function QuestionNavStrip(props) {
54
+ const current = props.useSessions((s) => s.current);
55
+ const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
56
+ const visible = current !== void 0 && summary !== void 0 && summary.blank !== true;
57
+ const [questions, setQuestions] = (0, react.useState)([]);
58
+ const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
59
+ const [hint, setHint] = (0, react.useState)(null);
60
+ const [tooltip, setTooltip] = (0, react.useState)(null);
61
+ const panelRef = (0, react.useRef)(null);
62
+ const hintTimerRef = (0, react.useRef)(null);
63
+ const showHint = (message) => {
64
+ setHint(message);
65
+ if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
66
+ hintTimerRef.current = window.setTimeout(() => setHint(null), 1800);
67
+ };
68
+ (0, react.useEffect)(() => {
69
+ if (!visible || current === void 0) {
70
+ setQuestions([]);
71
+ return;
72
+ }
73
+ const refresh = () => setQuestions(props.readQuestions(current));
74
+ refresh();
75
+ const unsubContent = props.subscribeContent(current, refresh);
76
+ const unsubList = props.subscribeList(refresh);
77
+ return () => {
78
+ unsubContent();
79
+ unsubList();
80
+ };
81
+ }, [
82
+ visible,
83
+ current,
84
+ props
85
+ ]);
86
+ (0, react.useEffect)(() => {
87
+ const onJumpFailed = (event) => {
88
+ const code = event.detail;
89
+ showHint(props.t(FAILURE_HINTS[code] ?? "jump.timeout"));
90
+ };
91
+ window.addEventListener("question-nav:jump-failed", onJumpFailed);
92
+ return () => window.removeEventListener("question-nav:jump-failed", onJumpFailed);
93
+ }, [props]);
94
+ (0, react.useLayoutEffect)(() => {
95
+ if (!visible) return;
96
+ let raf = 0;
97
+ let retries = 0;
98
+ const applyLayout = () => {
99
+ const panel = panelRef.current;
100
+ if (panel === null) return;
101
+ const frame = panel.closest("[data-shell-overlay]")?.parentElement ?? null;
102
+ const convRoot = findConvRoot();
103
+ if (frame === null || convRoot === null) return;
104
+ const frameRect = frame.getBoundingClientRect();
105
+ const convRect = convRoot.getBoundingClientRect();
106
+ if (convRect.height <= 0) {
107
+ if (retries < 20) {
108
+ retries += 1;
109
+ raf = requestAnimationFrame(applyLayout);
110
+ }
111
+ return;
112
+ }
113
+ retries = 0;
114
+ panel.style.top = `${convRect.top - frameRect.top}px`;
115
+ panel.style.height = `${convRect.height}px`;
116
+ panel.style.left = `${convRect.left - frameRect.left}px`;
117
+ };
118
+ applyLayout();
119
+ raf = requestAnimationFrame(applyLayout);
120
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(applyLayout);
121
+ const convRoot = findConvRoot();
122
+ observer?.observe(convRoot ?? document.body, { box: "border-box" });
123
+ window.addEventListener("resize", applyLayout);
124
+ return () => {
125
+ if (raf !== 0) cancelAnimationFrame(raf);
126
+ observer?.disconnect();
127
+ window.removeEventListener("resize", applyLayout);
128
+ };
129
+ }, [visible]);
130
+ (0, react.useEffect)(() => () => {
131
+ if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
132
+ }, []);
133
+ if (!visible) return null;
134
+ const onJump = (node) => {
135
+ if (current === void 0) return;
136
+ setJumpingKey(node.key);
137
+ props.jump(current, node.key);
138
+ window.setTimeout(() => setJumpingKey((k) => k === node.key ? null : k), 600);
139
+ };
140
+ const t = props.t;
141
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
142
+ ref: panelRef,
143
+ className: question_nav_module_css_default.rail,
144
+ "data-question-nav": "rail",
145
+ children: [
146
+ hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
147
+ className: question_nav_module_css_default.hint,
148
+ role: "status",
149
+ children: hint
150
+ }) : null,
151
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
152
+ className: question_nav_module_css_default.list,
153
+ children: questions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
154
+ className: question_nav_module_css_default.empty,
155
+ children: t("strip.empty")
156
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
157
+ 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))]
175
+ })
176
+ }),
177
+ tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
178
+ className: question_nav_module_css_default.tooltip,
179
+ style: {
180
+ left: tooltip.left,
181
+ top: tooltip.top
182
+ },
183
+ children: tooltip.text
184
+ }), document.body) : null
185
+ ]
186
+ });
187
+ }
188
+ //#endregion
189
+ //#region src/client/locales.ts
190
+ /**
191
+ * Locale dictionaries for the question-nav surface (zh/en). Registered under
192
+ * the `question-nav` namespace; keys are consumed through the bound translator.
193
+ */
194
+ const zh = {
195
+ "strip.empty": "本会话还没有提问",
196
+ "jump.inactive": "聊天视图未激活",
197
+ "jump.hidden": "目标无独立气泡,已定位到邻近内容",
198
+ "jump.notfound": "目标未加载或不存在(可能已压缩)",
199
+ "jump.timeout": "加载历史超时,可重试"
200
+ };
201
+ const en = {
202
+ "strip.empty": "No questions in this session yet",
203
+ "jump.inactive": "Chat view is not active",
204
+ "jump.hidden": "No dedicated bubble; landed on nearby content",
205
+ "jump.notfound": "Target not loaded or missing (maybe compacted)",
206
+ "jump.timeout": "Timed out loading history; retry"
207
+ };
208
+ //#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
+ //#region src/core/jump.ts
255
+ /**
256
+ * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
257
+ * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the
258
+ * paging/timeout/fallback loop is unit-testable without a real browser or
259
+ * session. The browser half wires these ports to ctx.sessions + the DOM.
260
+ */
261
+ const DEFAULTS = {
262
+ totalTimeoutMs: 15e3,
263
+ maxPages: 100,
264
+ rowWaitMs: 8e3,
265
+ pollMs: 60
266
+ };
267
+ function minAnchorSeq(rows) {
268
+ let min = null;
269
+ for (const row of rows) if (min === null || row.anchorSeq < min) min = row.anchorSeq;
270
+ return min;
271
+ }
272
+ function renderable(rows) {
273
+ const out = [];
274
+ for (const row of rows) {
275
+ if (row.visibility === "hidden") continue;
276
+ out.push({
277
+ key: row.key,
278
+ anchorSeq: row.anchorSeq
279
+ });
280
+ }
281
+ return out;
282
+ }
283
+ /**
284
+ * Jump to the row for `key`, paging older content until it is rendered (or the
285
+ * budget is exhausted). Falls back to the nearest renderable row when the
286
+ * exact row is hidden/absent.
287
+ */
288
+ async function jumpToQuestion(ports, key, options = {}) {
289
+ const cfg = {
290
+ ...DEFAULTS,
291
+ ...options
292
+ };
293
+ const fail = (code, fallback = false) => {
294
+ ports.report?.(code, fallback);
295
+ return fallback ? {
296
+ ok: false,
297
+ code,
298
+ fallback: true
299
+ } : {
300
+ ok: false,
301
+ code
302
+ };
303
+ };
304
+ if (!ports.isViewActive()) return fail("VIEW_INACTIVE");
305
+ const deadline = ports.now() + cfg.totalTimeoutMs;
306
+ let pages = 0;
307
+ while (true) {
308
+ const snap = ports.snapshot();
309
+ if (snap === void 0) return fail("VIEW_INACTIVE");
310
+ const rows = renderable(snap.rows);
311
+ if (rows.some((r) => r.key === key)) break;
312
+ if (snap.openState !== "open") {
313
+ if (snap.openState === "error" || ports.now() > deadline) return fail(snap.openState === "error" ? "VIEW_INACTIVE" : "TIMEOUT");
314
+ await ports.sleep(cfg.pollMs);
315
+ continue;
316
+ }
317
+ if (snap.hasMore !== true) return fail("NOT_FOUND");
318
+ if (pages >= cfg.maxPages || ports.now() > deadline) return fail("TIMEOUT");
319
+ if (snap.loadingOlder) {
320
+ await ports.sleep(cfg.pollMs);
321
+ continue;
322
+ }
323
+ const before = minAnchorSeq(rows);
324
+ await ports.loadOlder();
325
+ pages += 1;
326
+ const afterSnap = ports.snapshot();
327
+ const after = minAnchorSeq(afterSnap === void 0 ? [] : afterSnap.rows);
328
+ if (after === null || before !== null && after >= before) return fail("NOT_FOUND");
329
+ }
330
+ const waitedFor = async (rowKey) => {
331
+ for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {
332
+ if (!ports.isViewActive()) return null;
333
+ const row = ports.findRow(rowKey);
334
+ if (row !== null) return row;
335
+ await ports.sleep(cfg.pollMs);
336
+ }
337
+ return null;
338
+ };
339
+ const row = await waitedFor(key);
340
+ if (row !== null) {
341
+ ports.scrollIntoView(row);
342
+ return { ok: true };
343
+ }
344
+ const snap = ports.snapshot();
345
+ const fallback = nearestRenderable(snap === void 0 ? [] : snap.rows, key);
346
+ if (fallback !== null) {
347
+ const fbRow = await waitedFor(fallback.key);
348
+ if (fbRow !== null) {
349
+ ports.scrollIntoView(fbRow);
350
+ return fail("TARGET_HIDDEN", true);
351
+ }
352
+ }
353
+ return fail("TARGET_HIDDEN", false);
354
+ }
355
+ //#endregion
356
+ //#region src/client/index.ts
357
+ /** Locale namespace this plugin owns. */
358
+ const NS = "question-nav";
359
+ /** Services required by this plugin. */
360
+ const inject = [
361
+ "slots",
362
+ "locale",
363
+ "sessions"
364
+ ];
365
+ function claimApply() {
366
+ if (globalThis.__dshQuestionNavApplied === true) return false;
367
+ globalThis.__dshQuestionNavApplied = true;
368
+ return true;
369
+ }
370
+ function releaseApply() {
371
+ globalThis.__dshQuestionNavApplied = void 0;
372
+ }
373
+ /** Map the session snapshot to the jump-loop port surface. */
374
+ function jumpPortsFor(ctx, sessionId) {
375
+ return {
376
+ snapshot: () => {
377
+ const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
378
+ if (snap === void 0) return void 0;
379
+ return {
380
+ openState: snap.openState,
381
+ hasMore: snap.hasMore,
382
+ loadingOlder: snap.loadingOlder,
383
+ rows: snap.chat.nodes.values()
384
+ };
385
+ },
386
+ loadOlder: async () => {
387
+ const binding = ctx.sessions.binding(sessionId);
388
+ if (binding === void 0) throw new Error("session unavailable");
389
+ await binding.session.loadOlder();
390
+ },
391
+ isViewActive: () => document.querySelector("[data-chat-flow]") !== null,
392
+ findRow: (key) => {
393
+ for (const candidate of Array.from(document.querySelectorAll("[data-chat-anchor-key]"))) if (candidate.dataset.chatAnchorKey === key) return candidate;
394
+ return null;
395
+ },
396
+ scrollIntoView: (row) => row.scrollIntoView({ block: "start" }),
397
+ now: () => Date.now(),
398
+ sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))
399
+ };
400
+ }
401
+ function createInject(ctx) {
402
+ return {
403
+ readQuestions: (sessionId) => {
404
+ const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
405
+ if (snap === void 0) return [];
406
+ return extractQuestions(snap.chat.nodes.values());
407
+ },
408
+ subscribeList: (cb) => ctx.sessions.list.subscribe(cb),
409
+ subscribeContent: (sessionId, cb) => {
410
+ const binding = ctx.sessions.binding(sessionId);
411
+ if (binding === void 0) return () => {};
412
+ return binding.session.subscribe(cb);
413
+ },
414
+ jump: (sessionId, key) => {
415
+ const ports = jumpPortsFor(ctx, sessionId);
416
+ ports.report = (code) => {
417
+ window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
418
+ };
419
+ jumpToQuestion(ports, key);
420
+ }
421
+ };
422
+ }
423
+ /**
424
+ * Register the question-nav surface.
425
+ * @param ctx - client root context.
426
+ */
427
+ function apply(ctx) {
428
+ if (!claimApply()) return;
429
+ ctx.effect(() => releaseApply, "question-nav: apply claim");
430
+ ctx.effect(() => ctx.locale.register(NS, {
431
+ zh,
432
+ en
433
+ }), "question-nav: dictionaries");
434
+ const injected = createInject(ctx);
435
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
436
+ name: "shell.overlay",
437
+ id: "question-nav",
438
+ order: 900,
439
+ locale: NS,
440
+ inject: () => injected
441
+ }, QuestionNavStrip));
442
+ }
443
+ //#endregion
444
+ exports.apply = apply;
445
+ exports.inject = inject;
446
+ return module.exports;
447
+ }
448
+ });
449
+
450
+ //# sourceMappingURL=client.js.map