@luziyang2026/dsh-question-nav 0.4.0 → 0.4.2

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 CHANGED
@@ -1,29 +1,21 @@
1
- BSD 3-Clause License
1
+ MIT License
2
2
 
3
- Copyright (c) 2026, Abel Keith Sun. All rights reserved.
3
+ Copyright (c) 2026 Abel Keith Sun
4
4
 
5
- Redistribution and use in source and binary forms, with or without
6
- modification, are permitted provided that the following conditions are met:
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
7
11
 
8
- 1. Redistributions of source code must retain the above copyright notice,
9
- this list of conditions and the following disclaimer.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
10
14
 
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.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,22 +10,26 @@ It is an external plugin bundle with zero runtime dependencies — the browser
10
10
  half (`lib/client.js`) externalizes everything to the DSH shell, so it adds
11
11
  only a small bundle to the GUI at load time.
12
12
 
13
- Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm]).
13
+ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
14
+
15
+ ## Preview
16
+
17
+ ![Left-edge dot minimap with a hover tooltip showing the turn label and full question text](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
14
18
 
15
19
  ## What it does
16
20
 
17
21
  - **Left-edge dot minimap** (embedded, not reserving any width).
18
22
  - **Vertically centered** in the conversation column.
19
- - **One dot = one user question**, with a small count above the dot column.
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.
23
+ - **One dot = one turn that asked a question** (strictly aligned with the
24
+ Trajectory view's turn numbering), with a small count above the dot column.
25
+ - **Full history, persisted, no render-window expansion**: the plugin's host
26
+ half registers a `questionIndex` session projectionthe projection
27
+ registry folds the whole event log (read-only, the chat's paged window is
28
+ never touched), the official projection cache persists it across restarts,
29
+ and push frames deliver new questions live.
27
30
  - **Hover**: the dot enlarges and an instant tooltip (portal-rendered, no
28
- native-title delay) shows the question's **full text**.
31
+ native-title delay) shows the **turn label** (`Turn N`) plus the turn's
32
+ **full question text** (all of them, when one turn batched several).
29
33
  - **Click**: jumps to that question. Only then does the jump loop page the
30
34
  window (`loadOlder()`) to bring that specific page into view — never the
31
35
  whole history up front.
@@ -79,8 +83,9 @@ module table.
79
83
 
80
84
  ## License
81
85
 
82
- BSD-3-Clause.
86
+ MIT.
83
87
 
84
88
  [dsh]: https://github.com/deepseek-harness/deepseek-harness
85
89
  [npm]: https://www.npmjs.com/package/@luziyang2026/dsh-question-nav
90
+ [github]: https://github.com/AbelKeithsun/dsh-question-nav
86
91
  [bundle]: https://github.com/deepseek-harness/deepseek-harness
package/README.zh.md CHANGED
@@ -7,19 +7,24 @@
7
7
  它是外部插件 bundle,**零运行时依赖** —— 浏览器半区(`lib/client.js`)把一切
8
8
  外部化给 DSH 外壳,加载时只往 GUI 里加一个很小的 bundle。
9
9
 
10
- 包名:**`@luziyang2026/dsh-question-nav`**([npm][npm])。
10
+ 包名:**`@luziyang2026/dsh-question-nav`**([npm][npm] · [GitHub][github])。
11
+
12
+ ## 效果展示
13
+
14
+ ![左缘圆点迷你地图:竖排圆点,每个圆点对应一个用户提问,悬停显示 Turn 编号与全文](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
11
15
 
12
16
  ## 功能
13
17
 
14
18
  - **左缘圆点迷你地图**:内嵌,**不占任何宽度**。
15
19
  - **垂直居中**在对话栏中。
16
- - **一个圆点 = 一个用户提问**,圆点列上方有提问数量。
17
- - **完整历史、不展开渲染窗口**:索引通过分页读取原始 `session.history`
18
- RPC(只读)构建,所以会话里的每一个提问——包括仍被 DSH"加载更早"按钮
19
- 折叠隐藏的老提问——都会变成圆点,**不会**展开对话的分页窗口(保留 DSH
20
- 的内存经济性)。构建索引期间数量后显示 "…";若超过安全预算,最老提问
21
- 上方会出现一个虚线圆点,点击继续加载。
22
- - **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示**全文**。
20
+ - **一个圆点 = 一个含提问的 turn**(与轨迹视图的 Turn 编号严格对齐),
21
+ 圆点列上方有圆点数量。
22
+ - **完整历史、持久化、不展开渲染窗口**:插件的 host 半面注册
23
+ `questionIndex` 会话投影——投影注册表折叠完整事件日志(只读,对话的
24
+ 分页窗口完全不被动),官方 projection-cache 跨重启持久化,新提问通过
25
+ 推送帧实时到达。
26
+ - **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示
27
+ **Turn 编号**(`Turn N`)和该轮提问**全文**(一轮多条提问时全部列出)。
23
28
  - **点击**:跳转到该提问;只有这时跳转循环才 `loadOlder()` 扩窗,把**那一页**
24
29
  带进视图——绝不会一次性展开整个历史。
25
30
  - 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
@@ -69,8 +74,9 @@ externals 经加载器模块表解析。
69
74
 
70
75
  ## License
71
76
 
72
- BSD-3-Clause
77
+ MIT
73
78
 
74
79
  [dsh]: https://github.com/deepseek-harness/deepseek-harness
75
80
  [npm]: https://www.npmjs.com/package/@luziyang2026/dsh-question-nav
81
+ [github]: https://github.com/AbelKeithsun/dsh-question-nav
76
82
  [bundle]: https://github.com/deepseek-harness/deepseek-harness
package/lib/client.js CHANGED
@@ -18,13 +18,21 @@ window.__ModuleLoader__.load({
18
18
  function questionKey(id) {
19
19
  return `13:${MESSAGE_DEFINITION_KIND}${String(id)}`;
20
20
  }
21
+ /** True when entries are already in non-decreasing seq order (the projection
22
+ * appends in event order, so this is the common case and skips the sort). */
23
+ function isSortedBySeq(entries) {
24
+ for (let i = 1; i < entries.length; i++) if (entries[i].seq < entries[i - 1].seq) return false;
25
+ return true;
26
+ }
21
27
  /**
22
28
  * Fold the projection's question list into one dot per turn. Entries arrive
23
29
  * in event order; consecutive same-turn entries merge into a single dot whose
24
- * anchor is the turn's first question.
30
+ * anchor is the turn's first question. The input is expected to be sorted by
31
+ * seq; the defensive sort is skipped when it already is, so a long session
32
+ * never pays an O(n log n) sort on every content update.
25
33
  */
26
34
  function groupQuestionsByTurn(entries) {
27
- const sorted = [...entries].sort((a, b) => a.seq - b.seq);
35
+ const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq);
28
36
  const dots = [];
29
37
  for (const entry of sorted) {
30
38
  const key = questionKey(entry.id);
@@ -54,19 +62,41 @@ window.__ModuleLoader__.load({
54
62
  * whose key is already folded into a dot is dropped (the projected copy
55
63
  * wins); the rest become single-question dots with `turn: null`, inserted in
56
64
  * anchor-seq order so the strip stays strictly chronological.
65
+ *
66
+ * Fast path: when nothing new arrives the SAME array is returned (no copy),
67
+ * so the caller can bail out of a re-render on identical reference.
57
68
  */
58
69
  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
+ if (live.length === 0) return dots;
71
+ const known = /* @__PURE__ */ new Set();
72
+ for (const dot of dots) for (const key of dot.memberKeys) known.add(key);
73
+ const extras = [];
74
+ for (const question of live) {
75
+ if (known.has(question.key)) continue;
76
+ extras.push({
77
+ turn: null,
78
+ key: question.key,
79
+ anchorSeq: question.anchorSeq,
80
+ time: question.time,
81
+ texts: [question.text],
82
+ memberKeys: [question.key]
83
+ });
84
+ }
85
+ if (extras.length === 0) return dots;
86
+ const out = [];
87
+ let i = 0;
88
+ for (const extra of extras) {
89
+ while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {
90
+ out.push(dots[i]);
91
+ i += 1;
92
+ }
93
+ out.push(extra);
94
+ }
95
+ while (i < dots.length) {
96
+ out.push(dots[i]);
97
+ i += 1;
98
+ }
99
+ return out;
70
100
  }
71
101
  //#endregion
72
102
  //#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
@@ -129,6 +159,19 @@ window.__ModuleLoader__.load({
129
159
  function findConvRoot() {
130
160
  return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
131
161
  }
162
+ /** Structural equality of two dot lists (member keys fully capture a dot's
163
+ * folded questions, so identical key sequences mean identical content).
164
+ * Lets the strip skip a re-render when a refresh produced no change. */
165
+ function sameDots(a, b) {
166
+ if (a.length !== b.length) return false;
167
+ for (let i = 0; i < a.length; i++) {
168
+ const da = a[i];
169
+ const db = b[i];
170
+ if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false;
171
+ for (let j = 0; j < da.memberKeys.length; j++) if (da.memberKeys[j] !== db.memberKeys[j]) return false;
172
+ }
173
+ return true;
174
+ }
132
175
  function QuestionNavStrip(props) {
133
176
  const current = props.useSessions((s) => s.current);
134
177
  const summary = props.useSessions((s) => s.current === void 0 ? void 0 : s.byId[s.current]);
@@ -139,6 +182,7 @@ window.__ModuleLoader__.load({
139
182
  const [tooltip, setTooltip] = (0, react.useState)(null);
140
183
  const panelRef = (0, react.useRef)(null);
141
184
  const hintTimerRef = (0, react.useRef)(null);
185
+ const lastDotsRef = (0, react.useRef)([]);
142
186
  const showHint = (message) => {
143
187
  setHint(message);
144
188
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
@@ -147,22 +191,26 @@ window.__ModuleLoader__.load({
147
191
  (0, react.useEffect)(() => {
148
192
  if (!visible || current === void 0) {
149
193
  setDots([]);
194
+ lastDotsRef.current = [];
150
195
  return;
151
196
  }
152
197
  const sessionId = current;
153
198
  const face = props.questionProjection(sessionId);
154
199
  const refresh = () => {
155
- const grouped = groupQuestionsByTurn(projectionEntries(face));
156
- setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)));
200
+ const next = mergeLiveQuestions(groupQuestionsByTurn(projectionEntries(face)), props.readQuestions(sessionId));
201
+ if (sameDots(next, lastDotsRef.current)) {
202
+ setDots(lastDotsRef.current);
203
+ return;
204
+ }
205
+ lastDotsRef.current = next;
206
+ setDots(next);
157
207
  };
158
208
  refresh();
159
209
  const unsubProjection = face?.subscribe(refresh) ?? (() => {});
160
210
  const unsubContent = props.subscribeContent(sessionId, refresh);
161
- const unsubList = props.subscribeList(refresh);
162
211
  return () => {
163
212
  unsubProjection();
164
213
  unsubContent();
165
- unsubList();
166
214
  };
167
215
  }, [
168
216
  visible,
@@ -180,37 +228,72 @@ window.__ModuleLoader__.load({
180
228
  (0, react.useLayoutEffect)(() => {
181
229
  if (!visible) return;
182
230
  let raf = 0;
183
- let retries = 0;
231
+ let idle = 0;
232
+ let stopped = false;
233
+ let observer = null;
234
+ const observed = {
235
+ frame: null,
236
+ convRoot: null
237
+ };
184
238
  const applyLayout = () => {
185
239
  const panel = panelRef.current;
186
- if (panel === null) return;
240
+ if (panel === null) return false;
187
241
  const frame = panel.closest("[data-shell-overlay]")?.parentElement ?? null;
188
242
  const convRoot = findConvRoot();
189
- if (frame === null || convRoot === null) return;
190
- const frameRect = frame.getBoundingClientRect();
191
- const convRect = convRoot.getBoundingClientRect();
192
- if (convRect.height <= 0) {
193
- if (retries < 20) {
194
- retries += 1;
195
- raf = requestAnimationFrame(applyLayout);
243
+ if (frame === null || convRoot === null) return true;
244
+ if (observer !== null) {
245
+ if (observed.frame !== frame) {
246
+ observer.observe(frame, { box: "border-box" });
247
+ observed.frame = frame;
248
+ }
249
+ if (observed.convRoot !== convRoot) {
250
+ observer.observe(convRoot, { box: "border-box" });
251
+ observed.convRoot = convRoot;
196
252
  }
197
- return;
198
253
  }
199
- retries = 0;
200
- panel.style.top = `${convRect.top - frameRect.top}px`;
201
- panel.style.height = `${convRect.height}px`;
202
- panel.style.left = `${convRect.left - frameRect.left}px`;
254
+ const frameRect = frame.getBoundingClientRect();
255
+ const convRect = convRoot.getBoundingClientRect();
256
+ if (convRect.height <= 0 || convRect.width <= 0) return true;
257
+ const top = `${convRect.top - frameRect.top}px`;
258
+ const height = `${convRect.height}px`;
259
+ const left = `${convRect.left - frameRect.left}px`;
260
+ if (panel.style.top === top && panel.style.height === height && panel.style.left === left) return false;
261
+ panel.style.top = top;
262
+ panel.style.height = height;
263
+ panel.style.left = left;
264
+ return true;
265
+ };
266
+ const loop = () => {
267
+ if (stopped) return;
268
+ raf = 0;
269
+ idle = applyLayout() ? 0 : idle + 1;
270
+ if (idle < 3) raf = requestAnimationFrame(loop);
203
271
  };
204
- applyLayout();
205
- raf = requestAnimationFrame(applyLayout);
206
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(applyLayout);
207
- const convRoot = findConvRoot();
208
- observer?.observe(convRoot ?? document.body, { box: "border-box" });
209
- window.addEventListener("resize", applyLayout);
272
+ const wake = () => {
273
+ if (stopped) return;
274
+ idle = 0;
275
+ if (raf === 0) raf = requestAnimationFrame(loop);
276
+ };
277
+ observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(wake);
278
+ if (observer !== null) {
279
+ const frame = panelRef.current?.closest("[data-shell-overlay]")?.parentElement ?? null;
280
+ const convRoot = findConvRoot();
281
+ if (frame !== null) {
282
+ observer.observe(frame, { box: "border-box" });
283
+ observed.frame = frame;
284
+ }
285
+ if (convRoot !== null) {
286
+ observer.observe(convRoot, { box: "border-box" });
287
+ observed.convRoot = convRoot;
288
+ }
289
+ }
290
+ wake();
291
+ window.addEventListener("resize", wake);
210
292
  return () => {
293
+ stopped = true;
211
294
  if (raf !== 0) cancelAnimationFrame(raf);
212
295
  observer?.disconnect();
213
- window.removeEventListener("resize", applyLayout);
296
+ window.removeEventListener("resize", wake);
214
297
  };
215
298
  }, [visible]);
216
299
  (0, react.useEffect)(() => () => {
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal"],"sources":["../src/core/turn-dots.ts","../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n const known = new Set(dots.flatMap(dot => dot.memberKeys))\n const extras: TurnDot[] = live\n .filter(question => !known.has(question.key))\n .map(question => ({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n }))\n if (extras.length === 0) return [...dots]\n return [...dots, ...extras].sort((a, b) => a.anchorSeq - b.anchorSeq)\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per turn that\n * claimed at least one user question — strictly aligned with the Trajectory\n * view's turn numbering (turns without a question produce no dot). Hover\n * enlarges a dot and shows an instant tooltip (portal-rendered, no native\n * delay) with the turn label and the turn's question text(s); clicking jumps\n * the chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n /** Turn label line (e.g. \"Turn 32\"); null for ungrouped live questions. */\n title: string | null\n /** Question text lines (one per question folded into the dot). */\n lines: readonly string[]\n left: number\n top: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window; subscribe to\n // the projection push frames, session content, and the session list.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)))\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n unsubProjection()\n unsubContent()\n unsubList()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let retries = 0\n const applyLayout = (): void => {\n const panel = panelRef.current\n if (panel === null) return\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame === null || convRoot === null) return\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n if (convRect.height <= 0) {\n if (retries < 20) {\n retries += 1\n raf = requestAnimationFrame(applyLayout)\n }\n return\n }\n retries = 0\n panel.style.top = `${convRect.top - frameRect.top}px`\n panel.style.height = `${convRect.height}px`\n panel.style.left = `${convRect.left - frameRect.left}px`\n }\n applyLayout()\n raf = requestAnimationFrame(applyLayout)\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)\n const convRoot = findConvRoot()\n observer?.observe(convRoot ?? document.body, { box: 'border-box' })\n window.addEventListener('resize', applyLayout)\n return () => {\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', applyLayout)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openTooltip = (dot: TurnDot, target: HTMLElement): void => {\n const r = target.getBoundingClientRect()\n setTooltip({\n title: dot.turn === null ? null : `Turn ${dot.turn}`,\n lines: dot.texts,\n left: r.right + 10,\n top: r.top,\n })\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot) => (\n <button\n key={dot.key}\n className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(dot)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}\n {tooltip.lines.map((line, index) => (\n <div key={index} className={styles.tooltipLine}>{line}</div>\n ))}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal"],"sources":["../src/core/turn-dots.ts","../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per turn that\n * claimed at least one user question — strictly aligned with the Trajectory\n * view's turn numbering (turns without a question produce no dot). Hover\n * enlarges a dot and shows an instant tooltip (portal-rendered, no native\n * delay) with the turn label and the turn's question text(s); clicking jumps\n * the chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n /** Turn label line (e.g. \"Turn 32\"); null for ungrouped live questions. */\n title: string | null\n /** Question text lines (one per question folded into the dot). */\n lines: readonly string[]\n left: number\n top: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height && panel.style.left === left) {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openTooltip = (dot: TurnDot, target: HTMLElement): void => {\n const r = target.getBoundingClientRect()\n setTooltip({\n title: dot.turn === null ? null : `Turn ${dot.turn}`,\n lines: dot.texts,\n left: r.right + 10,\n top: r.top,\n })\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot) => (\n <button\n key={dot.key}\n className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(dot)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}\n {tooltip.lines.map((line, index) => (\n <div key={index} className={styles.tooltipLine}>{line}</div>\n ))}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":"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"}
@@ -35,7 +35,9 @@ export interface TurnDot {
35
35
  /**
36
36
  * Fold the projection's question list into one dot per turn. Entries arrive
37
37
  * in event order; consecutive same-turn entries merge into a single dot whose
38
- * anchor is the turn's first question.
38
+ * anchor is the turn's first question. The input is expected to be sorted by
39
+ * seq; the defensive sort is skipped when it already is, so a long session
40
+ * never pays an O(n log n) sort on every content update.
39
41
  */
40
42
  export declare function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[];
41
43
  /**
@@ -44,5 +46,8 @@ export declare function groupQuestionsByTurn(entries: readonly QuestionEntry[]):
44
46
  * whose key is already folded into a dot is dropped (the projected copy
45
47
  * wins); the rest become single-question dots with `turn: null`, inserted in
46
48
  * anchor-seq order so the strip stays strictly chronological.
49
+ *
50
+ * Fast path: when nothing new arrives the SAME array is returned (no copy),
51
+ * so the caller can bail out of a re-render on identical reference.
47
52
  */
48
53
  export declare function mergeLiveQuestions(dots: readonly TurnDot[], live: readonly QuestionNode[]): TurnDot[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luziyang2026/dsh-question-nav",
3
3
  "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the left edge of the conversation column, one dot per user question — hover enlarges and shows the full question text, click jumps to that message.",
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -69,7 +69,6 @@
69
69
  "react-dom": "^18.2.0",
70
70
  "tsdown": "^0.22.2",
71
71
  "typescript": "^6.0.3",
72
- "vite-tsconfig-paths": "^6.1.1",
73
72
  "vitest": "^4.1.8"
74
73
  },
75
74
  "files": [
@@ -78,10 +77,9 @@
78
77
  "cordis.patch.yml",
79
78
  "LICENSE",
80
79
  "README.md",
81
- "README.zh.md",
82
- "README.i18n.yaml"
80
+ "README.zh.md"
83
81
  ],
84
- "license": "BSD-3-Clause",
82
+ "license": "MIT",
85
83
  "repository": {
86
84
  "type": "git",
87
85
  "url": "https://github.com/AbelKeithsun/dsh-question-nav.git"
@@ -88,6 +88,22 @@ function findConvRoot(): HTMLElement | null {
88
88
  return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
89
89
  }
90
90
 
91
+ /** Structural equality of two dot lists (member keys fully capture a dot's
92
+ * folded questions, so identical key sequences mean identical content).
93
+ * Lets the strip skip a re-render when a refresh produced no change. */
94
+ function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
95
+ if (a.length !== b.length) return false
96
+ for (let i = 0; i < a.length; i++) {
97
+ const da = a[i]
98
+ const db = b[i]
99
+ if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false
100
+ for (let j = 0; j < da.memberKeys.length; j++) {
101
+ if (da.memberKeys[j] !== db.memberKeys[j]) return false
102
+ }
103
+ }
104
+ return true
105
+ }
106
+
91
107
  export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {
92
108
  const current = props.useSessions((s) => s.current)
93
109
  const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))
@@ -99,6 +115,8 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
99
115
  const [tooltip, setTooltip] = useState<TooltipState | null>(null)
100
116
  const panelRef = useRef<HTMLDivElement | null>(null)
101
117
  const hintTimerRef = useRef<number | null>(null)
118
+ // Last rendered dot list, for the change-detection bail-out below.
119
+ const lastDotsRef = useRef<TurnDot[]>([])
102
120
 
103
121
  const showHint = (message: string): void => {
104
122
  setHint(message)
@@ -106,27 +124,38 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
106
124
  hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
107
125
  }
108
126
 
109
- // Recompute the dot list from the projection + live window; subscribe to
110
- // the projection push frames, session content, and the session list.
127
+ // Recompute the dot list from the projection + live window. Subscribed to
128
+ // the projection push frames and the session's content only session
129
+ // switches are covered by `current` below (the effect re-runs on change),
130
+ // so the session-list feed is not subscribed: it would re-run the full
131
+ // recompute for unrelated list churn.
111
132
  useEffect(() => {
112
133
  if (!visible || current === undefined) {
113
134
  setDots([])
135
+ lastDotsRef.current = []
114
136
  return
115
137
  }
116
138
  const sessionId = current
117
139
  const face = props.questionProjection(sessionId)
118
140
  const refresh = (): void => {
119
141
  const grouped = groupQuestionsByTurn(projectionEntries(face))
120
- setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)))
142
+ const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))
143
+ // Identical content (a streaming update that added no question): re-use
144
+ // the previous array reference so React bails out of re-rendering the
145
+ // strip — the common case during assistant streaming.
146
+ if (sameDots(next, lastDotsRef.current)) {
147
+ setDots(lastDotsRef.current)
148
+ return
149
+ }
150
+ lastDotsRef.current = next
151
+ setDots(next)
121
152
  }
122
153
  refresh()
123
154
  const unsubProjection = face?.subscribe(refresh) ?? (() => {})
124
155
  const unsubContent = props.subscribeContent(sessionId, refresh)
125
- const unsubList = props.subscribeList(refresh)
126
156
  return () => {
127
157
  unsubProjection()
128
158
  unsubContent()
129
- unsubList()
130
159
  }
131
160
  // eslint-disable-next-line react-hooks/exhaustive-deps
132
161
  }, [visible, current, props])
@@ -143,40 +172,90 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
143
172
 
144
173
  // Anchor the minimap to the conversation column: position it at the left
145
174
  // edge of the conversation root and reserve a thin rail with padding-left.
175
+ //
176
+ // A layout-correction loop keeps the rail pinned to the conversation even
177
+ // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves
178
+ // or resizes the frame without resizing the browser window: `window resize`
179
+ // never fires for in-page panels, and a ResizeObserver on the conversation
180
+ // root alone misses remounts and the tail of the frame's grid-column
181
+ // transition, which used to leave the rail drifting into the session list.
182
+ // The loop runs while the layout is still moving, then parks itself after a
183
+ // few stable frames; observers re-wake it on the next change.
146
184
  useLayoutEffect(() => {
147
185
  if (!visible) return
148
186
  let raf = 0
149
- let retries = 0
150
- const applyLayout = (): void => {
187
+ let idle = 0
188
+ let stopped = false
189
+ let observer: ResizeObserver | null = null
190
+ const observed = { frame: null as Element | null, convRoot: null as Element | null }
191
+
192
+ const applyLayout = (): boolean => {
151
193
  const panel = panelRef.current
152
- if (panel === null) return
194
+ if (panel === null) return false
153
195
  const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null
154
196
  const convRoot = findConvRoot()
155
- if (frame === null || convRoot === null) return
197
+ // Keep looping while the anchors are not both present (conversation not
198
+ // mounted yet / mid-reflow), so a late mount still aligns.
199
+ if (frame === null || convRoot === null) return true
200
+ // Keep observing the live nodes: the conversation root may remount
201
+ // (e.g. after a panel-triggered reflow), which silently detaches an
202
+ // earlier ResizeObserver target.
203
+ if (observer !== null) {
204
+ if (observed.frame !== frame) {
205
+ observer.observe(frame, { box: 'border-box' })
206
+ observed.frame = frame
207
+ }
208
+ if (observed.convRoot !== convRoot) {
209
+ observer.observe(convRoot, { box: 'border-box' })
210
+ observed.convRoot = convRoot
211
+ }
212
+ }
156
213
  const frameRect = frame.getBoundingClientRect()
157
214
  const convRect = convRoot.getBoundingClientRect()
158
- if (convRect.height <= 0) {
159
- if (retries < 20) {
160
- retries += 1
161
- raf = requestAnimationFrame(applyLayout)
162
- }
163
- return
215
+ // Never snap onto a transient box: keep correcting until the
216
+ // conversation has a real footprint again.
217
+ if (convRect.height <= 0 || convRect.width <= 0) return true
218
+ const top = `${convRect.top - frameRect.top}px`
219
+ const height = `${convRect.height}px`
220
+ const left = `${convRect.left - frameRect.left}px`
221
+ if (panel.style.top === top && panel.style.height === height && panel.style.left === left) {
222
+ return false
164
223
  }
165
- retries = 0
166
- panel.style.top = `${convRect.top - frameRect.top}px`
167
- panel.style.height = `${convRect.height}px`
168
- panel.style.left = `${convRect.left - frameRect.left}px`
224
+ panel.style.top = top
225
+ panel.style.height = height
226
+ panel.style.left = left
227
+ return true
228
+ }
229
+
230
+ const loop = (): void => {
231
+ if (stopped) return
232
+ raf = 0
233
+ idle = applyLayout() ? 0 : idle + 1
234
+ if (idle < 3) raf = requestAnimationFrame(loop)
235
+ }
236
+ const wake = (): void => {
237
+ if (stopped) return
238
+ idle = 0
239
+ if (raf === 0) raf = requestAnimationFrame(loop)
169
240
  }
170
- applyLayout()
171
- raf = requestAnimationFrame(applyLayout)
172
- const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)
173
- const convRoot = findConvRoot()
174
- observer?.observe(convRoot ?? document.body, { box: 'border-box' })
175
- window.addEventListener('resize', applyLayout)
241
+
242
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)
243
+ if (observer !== null) {
244
+ const panel = panelRef.current
245
+ const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null
246
+ const convRoot = findConvRoot()
247
+ if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }
248
+ if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }
249
+ }
250
+
251
+ // Initial alignment; the loop keeps correcting through layout transitions.
252
+ wake()
253
+ window.addEventListener('resize', wake)
176
254
  return () => {
255
+ stopped = true
177
256
  if (raf !== 0) cancelAnimationFrame(raf)
178
257
  observer?.disconnect()
179
- window.removeEventListener('resize', applyLayout)
258
+ window.removeEventListener('resize', wake)
180
259
  }
181
260
  }, [visible])
182
261
 
@@ -40,13 +40,24 @@ export interface TurnDot {
40
40
  readonly memberKeys: readonly string[]
41
41
  }
42
42
 
43
+ /** True when entries are already in non-decreasing seq order (the projection
44
+ * appends in event order, so this is the common case and skips the sort). */
45
+ function isSortedBySeq(entries: readonly QuestionEntry[]): boolean {
46
+ for (let i = 1; i < entries.length; i++) {
47
+ if (entries[i].seq < entries[i - 1].seq) return false
48
+ }
49
+ return true
50
+ }
51
+
43
52
  /**
44
53
  * Fold the projection's question list into one dot per turn. Entries arrive
45
54
  * in event order; consecutive same-turn entries merge into a single dot whose
46
- * anchor is the turn's first question.
55
+ * anchor is the turn's first question. The input is expected to be sorted by
56
+ * seq; the defensive sort is skipped when it already is, so a long session
57
+ * never pays an O(n log n) sort on every content update.
47
58
  */
48
59
  export function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {
49
- const sorted = [...entries].sort((a, b) => a.seq - b.seq)
60
+ const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)
50
61
  const dots: TurnDot[] = []
51
62
  for (const entry of sorted) {
52
63
  const key = questionKey(entry.id)
@@ -77,22 +88,49 @@ export function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot
77
88
  * whose key is already folded into a dot is dropped (the projected copy
78
89
  * wins); the rest become single-question dots with `turn: null`, inserted in
79
90
  * anchor-seq order so the strip stays strictly chronological.
91
+ *
92
+ * Fast path: when nothing new arrives the SAME array is returned (no copy),
93
+ * so the caller can bail out of a re-render on identical reference.
80
94
  */
81
95
  export function mergeLiveQuestions(
82
96
  dots: readonly TurnDot[],
83
97
  live: readonly QuestionNode[],
84
98
  ): TurnDot[] {
85
- const known = new Set(dots.flatMap(dot => dot.memberKeys))
86
- const extras: TurnDot[] = live
87
- .filter(question => !known.has(question.key))
88
- .map(question => ({
99
+ if (live.length === 0) return dots as TurnDot[]
100
+ const known = new Set<string>()
101
+ for (const dot of dots) {
102
+ for (const key of dot.memberKeys) known.add(key)
103
+ }
104
+ const extras: TurnDot[] = []
105
+ for (const question of live) {
106
+ if (known.has(question.key)) continue
107
+ extras.push({
89
108
  turn: null,
90
109
  key: question.key,
91
110
  anchorSeq: question.anchorSeq,
92
111
  time: question.time,
93
112
  texts: [question.text],
94
113
  memberKeys: [question.key],
95
- }))
96
- if (extras.length === 0) return [...dots]
97
- return [...dots, ...extras].sort((a, b) => a.anchorSeq - b.anchorSeq)
114
+ })
115
+ }
116
+ // Nothing new from the live window — reuse the input array unchanged.
117
+ if (extras.length === 0) return dots as TurnDot[]
118
+ // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection
119
+ // order, extras from the live window order): merge linearly instead of
120
+ // re-sorting the whole list. Ties keep the projected dot first (stable
121
+ // sort semantics), matching the previous [...dots, ...extras].sort().
122
+ const out: TurnDot[] = []
123
+ let i = 0
124
+ for (const extra of extras) {
125
+ while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {
126
+ out.push(dots[i])
127
+ i += 1
128
+ }
129
+ out.push(extra)
130
+ }
131
+ while (i < dots.length) {
132
+ out.push(dots[i])
133
+ i += 1
134
+ }
135
+ return out
98
136
  }
@@ -1,99 +0,0 @@
1
- /**
2
- * Question-index builder over the raw session history RPC.
3
- *
4
- * DSH pages the rendered conversation window on purpose (memory economy):
5
- * `chat.nodes` only ever holds the loaded window, and force-expanding it
6
- * (repeated `loadOlder()`) materializes + renders the whole log — the exact
7
- * cost DSH's paging exists to avoid. This module instead builds a lightweight
8
- * index of every user question by paging the RAW history RPC (`session.history`
9
- * with `beforeSeq`), which reads the host log without touching the render
10
- * window at all. Only `{key, seq, time, text}` per question is retained.
11
- *
12
- * The chat anchor key is derived deterministically from the event — it equals
13
- * `conversationContextKey('input-message', String(event.data.id))` — so the
14
- * dots can target rows that are not loaded yet, and a click then pages the
15
- * window on demand (see `jump.ts`).
16
- *
17
- * Pure-ish: takes injected ports (one raw history page read, clocks) so it is
18
- * unit-testable without a browser or a live session.
19
- */
20
- import type { QuestionNode } from './nodes.ts';
21
- /** Minimal shape of a raw history event (structural, not SDK-bound). */
22
- export interface RawEventLike {
23
- type: string;
24
- seq: number;
25
- time: number;
26
- surfaceOp?: unknown;
27
- data?: {
28
- id?: unknown;
29
- source?: {
30
- kind?: string;
31
- plugin?: string;
32
- };
33
- content?: readonly {
34
- type?: string;
35
- text?: string;
36
- }[];
37
- };
38
- }
39
- /** The conversation Definition kind whose key a user question node uses. */
40
- export declare const MESSAGE_DEFINITION_KIND = "input-message";
41
- /**
42
- * The engine-owned stable chat key for a user question event — mirrors
43
- * `conversationContextKey('input-message', String(id))` from the DSH runtime
44
- * (verified against it in the unit test).
45
- */
46
- export declare function questionKey(id: unknown): string;
47
- /**
48
- * Whether a raw event is one user question the strip should index.
49
- * Mirrors the DSH `messageDefinition` match + `start` classification:
50
- * an append-origin `user/message` with a human (`user`) source. Replacement
51
- * copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
52
- * context (`source.kind !== 'user'`) are excluded.
53
- */
54
- export declare function isQuestionEvent(event: RawEventLike): boolean;
55
- /** Map one raw question event to a strip question node, or null when not one. */
56
- export declare function questionFromEvent(event: RawEventLike): QuestionNode | null;
57
- export interface HistoryIndexPorts {
58
- /**
59
- * Read one raw history page. `beforeSeq` is exclusive (events with seq <
60
- * beforeSeq); `undefined` reads the newest page. Resolves undefined when
61
- * the page is unavailable (session gone / transport error).
62
- */
63
- history: (beforeSeq: number | undefined, maxMessages: number) => Promise<{
64
- events: readonly {
65
- event: RawEventLike;
66
- }[];
67
- hasMore: boolean;
68
- } | undefined>;
69
- /** Monotonic ms clock. */
70
- now: () => number;
71
- }
72
- export interface HistoryIndexOptions {
73
- /** Raw messages per page (default 100). */
74
- maxMessages?: number;
75
- /** Max pages before giving up (default 200 => 20k messages). */
76
- maxPages?: number;
77
- /** Total wall-clock budget (default 30s). */
78
- totalTimeoutMs?: number;
79
- /** Abort the build; checked every iteration. */
80
- signal?: AbortSignal;
81
- /** Resume from a previous `nextBeforeSeq` instead of the newest page. */
82
- startBeforeSeq?: number;
83
- }
84
- export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED';
85
- export interface HistoryIndexResult {
86
- ok: boolean;
87
- code: HistoryIndexCode;
88
- /** Questions collected so far, ascending by anchorSeq. */
89
- questions: QuestionNode[];
90
- /** Page count actually read. */
91
- pages: number;
92
- /** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
93
- nextBeforeSeq: number | undefined;
94
- }
95
- /**
96
- * Page the raw session history backward, collecting every user question into a
97
- * lightweight index. Never touches the render window.
98
- */
99
- export declare function buildQuestionIndex(ports: HistoryIndexPorts, options?: HistoryIndexOptions): Promise<HistoryIndexResult>;
@@ -1,53 +0,0 @@
1
- /**
2
- * Load-all orchestration for the question-nav strip.
3
- *
4
- * DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
5
- * the currently loaded window, and questions that still sit behind the "load
6
- * older" button are invisible to the strip until the window is expanded
7
- * backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
8
- * history is materialized), so every user question becomes a dot.
9
- *
10
- * Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
11
- * liveness, clocks) so it is unit-testable without a browser or session.
12
- */
13
- export interface LoadAllSnapshot {
14
- openState: string;
15
- hasMore: boolean;
16
- loadingOlder: boolean;
17
- }
18
- export interface LoadAllPorts {
19
- /** Read the current session snapshot; undefined when unavailable. */
20
- snapshot: () => LoadAllSnapshot | undefined;
21
- /** Expand the window backwards by one page (may preserve scroll). */
22
- loadOlder: () => Promise<void>;
23
- /** True while the chat view is active (a `[data-chat-flow]` is mounted). */
24
- isViewActive: () => boolean;
25
- /** Monotonic ms clock. */
26
- now: () => number;
27
- /** Async sleep. */
28
- sleep: (ms: number) => Promise<void>;
29
- }
30
- export interface LoadAllOptions {
31
- /** Max older pages to fetch before giving up (default 400). */
32
- maxPages?: number;
33
- /** Total wall-clock budget for the whole expansion (default 60s). */
34
- totalTimeoutMs?: number;
35
- /** Poll interval for open/loading transitions (default 60ms). */
36
- pollMs?: number;
37
- /** Abort the expansion; checked every iteration. */
38
- signal?: AbortSignal;
39
- }
40
- export type LoadAllCode = 'COMPLETE' | 'VIEW_INACTIVE' | 'NOT_OPEN' | 'BUDGET' | 'TIMEOUT' | 'CANCELLED';
41
- export interface LoadAllResult {
42
- ok: boolean;
43
- code: LoadAllCode;
44
- /** Number of `loadOlder` pages actually fetched. */
45
- pages: number;
46
- }
47
- /**
48
- * Expand the session window backwards until the earliest history is loaded.
49
- * Waits while the session is still opening; aborts on cancellation, budget or
50
- * timeout. Safe to re-enter: once `hasMore` is false the loop returns
51
- * immediately with `COMPLETE`.
52
- */
53
- export declare function loadAllOlder(ports: LoadAllPorts, options?: LoadAllOptions): Promise<LoadAllResult>;