dsh-milestone 0.2.1 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +10 -3
  2. package/lib/client.js +545 -107
  3. package/package.json +13 -3
package/README.md CHANGED
@@ -30,6 +30,9 @@
30
30
  ## 好用在哪?
31
31
 
32
32
  - **一键定位** —— 点击任意圆点,平滑滚动到那条消息,不用再手动翻几百行。
33
+ - **站内搜索** —— 搜索框过滤圆点,匹配的是**完整消息内容**(不是 80 字摘要),实时显示命中数 N/M,回车跳到下一个匹配,Esc 一键清空。
34
+ - **当前位置高亮** —— 滚动会话时,离你视口最近的那条提问会亮起白环,永远知道「读到哪了」。
35
+ - **加载更早** —— 历史没加载完时,顶部出现「···」按钮,点一下继续加载,并提示当前已显示多少条。
33
36
  - **固定间距** —— 圆点**等距排列**,不随对话长度挤压变形,永远点得准。
34
37
  - **蓝色渐变** —— 最新最深、最早最浅,一眼看清提问的先后顺序,像 Git 提交图。
35
38
  - **滚轮滑动** —— 长会话圆点超出可视区时,鼠标在里程碑条上滚轮即可滑动选点。
@@ -51,9 +54,12 @@
51
54
  ## 快速开始
52
55
 
53
56
  ```sh
54
- # 安装插件到某个 profile
57
+ # npm 安装(推荐)
55
58
  dsh plugin --profile demo add dsh-milestone
56
59
 
60
+ # 或从 GitHub 源码安装
61
+ dsh plugin --profile demo add "github:SnowCrescenter-tech/dsh-milestone#main"
62
+
57
63
  # 启动 Web UI
58
64
  npx @deepseek-ai/dsh web # → http://127.0.0.1:3080
59
65
  ```
@@ -73,12 +79,13 @@ shell.overlay (root scope)
73
79
  ```
74
80
 
75
81
  - **注入点**:`shell.overlay` —— 全框架浮动层,附加式、点击穿透,不影响任何现有 UI。
76
- - **数据源**:`chat.order` + `chat.nodes`(user 消息)+ `chat.timeline`(turn 元数据)。
82
+ - **数据源**:`chat.order` + `chat.nodes`(user 消息)+ `chat.timeline`(turn 元数据)+ `hasMore`/`loadingOlder`(分页状态)+ `loadOlder`(经 inject face 注入)。
77
83
  - **跳转**:DOM 锚点 `data-chat-anchor-key`,`scrollIntoView` 平滑定位。
84
+ - **纯函数**:搜索过滤 / 位置计算 / 圆点状态都在 `rail-logic.ts` 纯函数里,单测覆盖。
78
85
 
79
86
  ## 已知限制
80
87
 
81
- - 仅覆盖当前已加载的消息窗口(初始 50 条,向上滚动分页会自动补圆点)。
88
+ - 搜索范围 = 当前已加载的消息窗口(初始 50 条;点顶部「···」加载更早,更早的历史需先加载进来才能被搜到)。
82
89
  - TTFT / tokens/秒 依赖 turn 位置数据,窗口外或未完成的 turn 不显示(自动隐藏)。
83
90
 
84
91
  ## License
package/lib/client.js CHANGED
@@ -18,6 +18,283 @@ window.__ModuleLoader__.load({
18
18
  });
19
19
  }
20
20
  //#endregion
21
+ //#region src/client/rail-logic.ts
22
+ /**
23
+ * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
24
+ * `{ type: 'text', text: string }` block, joined with a single space and
25
+ * trimmed. Unlike the rail's hover preview this is NOT truncated — callers use
26
+ * it for search matching, so the entire message must be searchable.
27
+ * @param content - untrusted payload; anything that is not an array yields ''.
28
+ */
29
+ function extractText(content) {
30
+ if (!Array.isArray(content)) return "";
31
+ const parts = [];
32
+ for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
33
+ const text = block.text;
34
+ if (typeof text === "string") parts.push(text);
35
+ }
36
+ return parts.join(" ").trim();
37
+ }
38
+ /**
39
+ * Case-insensitive substring filter over mark texts.
40
+ * @param marks - marks in rail order.
41
+ * @param query - the search query; empty/whitespace matches everything.
42
+ * @returns `matches` (ascending indices whose text includes the lowercased
43
+ * query; all indices when the query is blank) and `active` (the first match
44
+ * index, or -1 when the query is blank or nothing matches).
45
+ */
46
+ function filterMarks(marks, query) {
47
+ const q = query.trim();
48
+ if (q === "") return {
49
+ matches: marks.map((_, i) => i),
50
+ active: -1
51
+ };
52
+ const lower = q.toLowerCase();
53
+ const matches = marks.reduce((acc, mark, i) => {
54
+ if (mark.text.toLowerCase().includes(lower)) acc.push(i);
55
+ return acc;
56
+ }, []);
57
+ return {
58
+ matches,
59
+ active: matches.length > 0 ? matches[0] : -1
60
+ };
61
+ }
62
+ /**
63
+ * Wrap-around match navigation.
64
+ * @param current - the currently active match index (any number; used raw).
65
+ * @param count - number of matches; `<= 0` yields -1.
66
+ * @param delta - +1 to advance, -1 to go back.
67
+ * @returns `(current + delta + count) % count`, or -1 when count <= 0.
68
+ */
69
+ function nextMatchIndex(current, count, delta) {
70
+ if (count <= 0) return -1;
71
+ return (current + delta + count) % count;
72
+ }
73
+ /**
74
+ * Key of the row the viewport top currently sits in: the last row whose top is
75
+ * at or just above the viewport top (within a 0.5px epsilon).
76
+ * @param rows - rows in document order (ascending top).
77
+ * @param viewportTop - scrollport's current scroll offset.
78
+ * @returns that row's key; the first row's key when every row is below the
79
+ * viewport; undefined when there are no rows.
80
+ */
81
+ function currentIndexOf(rows, viewportTop) {
82
+ if (rows.length === 0) return void 0;
83
+ let current = rows[0];
84
+ for (const row of rows) if (row.top <= viewportTop + .5) current = row;
85
+ else break;
86
+ return current.key;
87
+ }
88
+ /**
89
+ * Compute a mark's visual state from search + position signals.
90
+ * Precedence: `current` (row at viewport top) > `active` (first query match) >
91
+ * `match` (any query match) > `dimmed` (query active, not a match) > `normal`.
92
+ * @param opts - the mark's signals (key is kept for caller symmetry).
93
+ */
94
+ function markState(opts) {
95
+ if (opts.isCurrent) return "current";
96
+ if (opts.isActive) return "active";
97
+ if (opts.isMatch) return "match";
98
+ if (opts.hasQuery) return "dimmed";
99
+ return "normal";
100
+ }
101
+ /**
102
+ * Blue gradient dot color, reproduced exactly from MilestoneRail: newest
103
+ * (highest index) is deepest, oldest is lightest. 72% lightness fading to 45%.
104
+ * @param index - dot position in the rail (0 = oldest).
105
+ * @param total - number of dots.
106
+ */
107
+ function dotColor(index, total) {
108
+ return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
109
+ }
110
+ //#endregion
111
+ //#region src/client/MilestoneRailSearch.tsx
112
+ /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
113
+ const DOT_HIT$1 = 22;
114
+ /**
115
+ * @param props - the search state slice plus the rail's event handlers.
116
+ */
117
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear }) {
118
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
119
+ type: "button",
120
+ "data-search-toggle": true,
121
+ "aria-label": "搜索消息",
122
+ "aria-pressed": panelOpen,
123
+ onClick: onToggle,
124
+ style: {
125
+ width: DOT_HIT$1,
126
+ height: DOT_HIT$1,
127
+ flexShrink: 0,
128
+ display: "flex",
129
+ alignItems: "center",
130
+ justifyContent: "center",
131
+ background: panelOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
132
+ border: "none",
133
+ padding: 0,
134
+ cursor: "pointer",
135
+ color: panelOpen ? "#9db8ff" : "#8b96ab"
136
+ },
137
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
138
+ width: "13",
139
+ height: "13",
140
+ viewBox: "0 0 24 24",
141
+ fill: "none",
142
+ stroke: "currentColor",
143
+ strokeWidth: "2.5",
144
+ strokeLinecap: "round",
145
+ "aria-hidden": "true",
146
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
147
+ cx: "11",
148
+ cy: "11",
149
+ r: "7"
150
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21 21-4.3-4.3" })]
151
+ })
152
+ }), panelOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
153
+ style: {
154
+ position: "fixed",
155
+ top: panelTop,
156
+ right: panelRight,
157
+ width: 220,
158
+ padding: "10px 12px",
159
+ background: "rgba(20, 24, 32, 0.97)",
160
+ color: "#e6e8ee",
161
+ borderRadius: 8,
162
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
163
+ zIndex: 102
164
+ },
165
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
166
+ style: {
167
+ display: "flex",
168
+ alignItems: "center",
169
+ gap: 6
170
+ },
171
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
172
+ "data-rail-search": true,
173
+ "aria-label": "搜索消息",
174
+ placeholder: "搜索消息内容",
175
+ value: query,
176
+ onChange: (e) => onQueryChange(e.target.value),
177
+ onKeyDown: onSearchKeyDown,
178
+ autoFocus: true,
179
+ style: {
180
+ flex: 1,
181
+ minWidth: 0,
182
+ padding: "5px 8px",
183
+ fontSize: 12,
184
+ color: "#e6e8ee",
185
+ background: "rgba(255, 255, 255, 0.08)",
186
+ border: "1px solid rgba(255, 255, 255, 0.16)",
187
+ borderRadius: 6,
188
+ outline: "none"
189
+ }
190
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
191
+ type: "button",
192
+ "data-search-clear": true,
193
+ "aria-label": "清空搜索",
194
+ onClick: onClear,
195
+ style: {
196
+ width: 22,
197
+ height: 22,
198
+ flexShrink: 0,
199
+ display: "flex",
200
+ alignItems: "center",
201
+ justifyContent: "center",
202
+ background: "transparent",
203
+ border: "none",
204
+ padding: 0,
205
+ cursor: "pointer",
206
+ color: "#8b96ab"
207
+ },
208
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
209
+ width: "10",
210
+ height: "10",
211
+ viewBox: "0 0 24 24",
212
+ fill: "none",
213
+ stroke: "currentColor",
214
+ strokeWidth: "3",
215
+ strokeLinecap: "round",
216
+ "aria-hidden": "true",
217
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
218
+ })
219
+ })]
220
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
221
+ "data-match-count": true,
222
+ style: {
223
+ marginTop: 6,
224
+ fontSize: 11,
225
+ color: "#8b96ab"
226
+ },
227
+ children: [
228
+ matches,
229
+ "/",
230
+ total
231
+ ]
232
+ })]
233
+ })] });
234
+ }
235
+ //#endregion
236
+ //#region src/client/useCurrentAnchor.ts
237
+ /**
238
+ * useCurrentAnchor: tracks which user-message row sits at/just above the
239
+ * conversation scrollport's top, so the rail can light the corresponding dot
240
+ * (F2 current-position highlight).
241
+ *
242
+ * Resolves the harness DOM shape (`[data-conversation-scroll]` containing
243
+ * `[data-chat-anchor-key]` rows), computes each row's offset top within the
244
+ * scrollport, and feeds them to `rail-logic.currentIndexOf(rows, 0)` — the
245
+ * viewport top is 0 in scrollport-relative coordinates.
246
+ *
247
+ * Recomputes on scrollport `scroll` events; when `IntersectionObserver` exists
248
+ * (real browsers) rows are also observed (root = scrollport, threshold 0) so
249
+ * layout changes that move a row across the top without a scroll event still
250
+ * refresh. In jsdom tests the observer stub is a no-op, so the scroll listener
251
+ * is the path component tests drive.
252
+ *
253
+ * Pure observation: no timers, no polling; geometry is read only on events.
254
+ */
255
+ /**
256
+ * @param order - the ordered chat node keys; a change re-resolves the DOM
257
+ * rows (new messages appended, load-older prepends, ...).
258
+ * @returns the anchor key of the row at/just above the scrollport top, or
259
+ * undefined when the scrollport is missing or has no rows.
260
+ */
261
+ function useCurrentAnchor(order) {
262
+ const [current, setCurrent] = (0, react.useState)(void 0);
263
+ (0, react.useEffect)(() => {
264
+ const scrollport = document.querySelector("[data-conversation-scroll]");
265
+ if (scrollport === null) {
266
+ setCurrent(void 0);
267
+ return;
268
+ }
269
+ const rows = [...scrollport.querySelectorAll("[data-chat-anchor-key]")];
270
+ const compute = () => {
271
+ const viewportTop = scrollport.getBoundingClientRect().top;
272
+ const positioned = rows.map((row) => ({
273
+ key: row.dataset.chatAnchorKey ?? "",
274
+ top: row.getBoundingClientRect().top - viewportTop
275
+ }));
276
+ setCurrent(currentIndexOf(positioned, 0));
277
+ };
278
+ compute();
279
+ scrollport.addEventListener("scroll", compute, { passive: true });
280
+ if (typeof IntersectionObserver !== "undefined") {
281
+ const observer = new IntersectionObserver(compute, {
282
+ root: scrollport,
283
+ threshold: [0]
284
+ });
285
+ for (const row of rows) observer.observe(row);
286
+ return () => {
287
+ observer.disconnect();
288
+ scrollport.removeEventListener("scroll", compute);
289
+ };
290
+ }
291
+ return () => {
292
+ scrollport.removeEventListener("scroll", compute);
293
+ };
294
+ }, [order]);
295
+ return current;
296
+ }
297
+ //#endregion
21
298
  //#region src/client/MilestoneRail.tsx
22
299
  /**
23
300
  * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
@@ -35,6 +312,22 @@ window.__ModuleLoader__.load({
35
312
  *
36
313
  * Positioning: the rail hugs the conversation scrollport's right edge (offset a
37
314
  * little inward so it clears the native scrollbar and sits near the prose).
315
+ *
316
+ * In-rail search (F1): a magnifier toggle at the rail top opens a compact
317
+ * panel to the rail's left with a message-text search input; matches light up
318
+ * the dots (non-matches dim), Enter cycles the active match (wrapping) and
319
+ * jumps to it, Escape clears and closes. Matching runs over the FULL message
320
+ * text (`text` from rail-logic.extractText), not the truncated hover preview.
321
+ *
322
+ * Current-position highlight (F2): the dot for the user message at/just above
323
+ * the conversation viewport top carries a white ring (`useCurrentAnchor`
324
+ * observes the scrollport, no polling).
325
+ *
326
+ * Load-older + window coverage (F3): when the session still has earlier pages
327
+ * (`hasMore`) a slim `···` button sits at the rail top and triggers the
328
+ * injected `loadOlder` action (disabled + `data-loading-older` while
329
+ * `loadingOlder`), and a compact hint to the rail's left states how many
330
+ * messages the current window covers.
38
331
  */
39
332
  /** Minimum user messages before the rail adds value. */
40
333
  const MIN_MARKS = 2;
@@ -55,19 +348,9 @@ window.__ModuleLoader__.load({
55
348
  for (const row of document.querySelectorAll("[data-chat-anchor-key]")) if (row.dataset.chatAnchorKey === key) return row;
56
349
  return null;
57
350
  }
58
- /** Extract a plain-text preview from a user message's ContentBlock[] payload. */
351
+ /** Extract a plain-text hover preview (first 80 chars) from a ContentBlock[]. */
59
352
  function extractPreview(content) {
60
- if (!Array.isArray(content)) return "";
61
- let text = "";
62
- for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
63
- const t = block.text;
64
- if (typeof t === "string") text += (text === "" ? "" : " ") + t;
65
- }
66
- return text.trim().slice(0, PREVIEW_LENGTH);
67
- }
68
- /** Blue gradient: newest (last) dots are deepest, oldest are lightest. */
69
- function dotColor(index, total) {
70
- return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
353
+ return extractText(content).slice(0, PREVIEW_LENGTH);
71
354
  }
72
355
  /** Relative wall-clock label for a Unix-epoch-ms timestamp. */
73
356
  function formatRelativeTime(time) {
@@ -104,10 +387,12 @@ window.__ModuleLoader__.load({
104
387
  /**
105
388
  * @param props - session standard kit (useSession, sessionId, useProjection).
106
389
  */
107
- function MilestoneRail({ useSession }) {
390
+ function MilestoneRail({ useSession, loadOlder }) {
108
391
  const order = useSession((s) => s.chat.order);
109
392
  const nodes = useSession((s) => s.chat.nodes);
110
393
  const timeline = useSession((s) => s.chat.timeline);
394
+ const hasMore = useSession((s) => s.hasMore);
395
+ const loadingOlder = useSession((s) => s.loadingOlder);
111
396
  const marks = (0, react.useMemo)(() => {
112
397
  const result = [];
113
398
  for (const key of order) {
@@ -120,6 +405,7 @@ window.__ModuleLoader__.load({
120
405
  turn,
121
406
  seq: data.seq ?? 0,
122
407
  time: data.time ?? 0,
408
+ text: extractText(data.content),
123
409
  preview: extractPreview(data.content)
124
410
  });
125
411
  }
@@ -127,6 +413,15 @@ window.__ModuleLoader__.load({
127
413
  }, [order, nodes]);
128
414
  const [railBox, setRailBox] = (0, react.useState)(null);
129
415
  const [hover, setHover] = (0, react.useState)(null);
416
+ const [search, setSearch] = (0, react.useState)({
417
+ query: "",
418
+ activePos: 0,
419
+ panelOpen: false
420
+ });
421
+ const currentKey = useCurrentAnchor(order);
422
+ const { matches } = (0, react.useMemo)(() => filterMarks(marks, search.query), [marks, search.query]);
423
+ const hasQuery = search.query.trim() !== "";
424
+ const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
130
425
  (0, react.useLayoutEffect)(() => {
131
426
  if (marks.length < MIN_MARKS) {
132
427
  setRailBox(null);
@@ -158,6 +453,41 @@ window.__ModuleLoader__.load({
158
453
  block: "start"
159
454
  });
160
455
  };
456
+ const updateQuery = (query) => {
457
+ setSearch({
458
+ query,
459
+ activePos: 0,
460
+ panelOpen: true
461
+ });
462
+ };
463
+ const clearSearch = () => {
464
+ setSearch((s) => ({
465
+ ...s,
466
+ query: "",
467
+ activePos: 0
468
+ }));
469
+ };
470
+ const closeSearch = () => {
471
+ setSearch({
472
+ query: "",
473
+ activePos: 0,
474
+ panelOpen: false
475
+ });
476
+ };
477
+ /** Enter: cycle to the next match (wrapping) and jump to that dot's row. */
478
+ const advanceMatch = () => {
479
+ if (matches.length === 0) return;
480
+ const next = nextMatchIndex(search.activePos, matches.length, 1);
481
+ setSearch((s) => ({
482
+ ...s,
483
+ activePos: next
484
+ }));
485
+ jump(marks[matches[next]].key);
486
+ };
487
+ const onSearchKeyDown = (e) => {
488
+ if (e.key === "Enter") advanceMatch();
489
+ if (e.key === "Escape") closeSearch();
490
+ };
161
491
  const buildHover = (mark, index) => {
162
492
  const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
163
493
  let durationLabel = null;
@@ -187,6 +517,7 @@ window.__ModuleLoader__.load({
187
517
  tpsLabel
188
518
  };
189
519
  };
520
+ const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
190
521
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
191
522
  style: {
192
523
  position: "fixed",
@@ -195,22 +526,22 @@ window.__ModuleLoader__.load({
195
526
  height: railBox.height,
196
527
  width: DOT_HIT,
197
528
  pointerEvents: "auto",
198
- zIndex: 100
529
+ zIndex: 100,
530
+ display: "flex",
531
+ flexDirection: "column"
199
532
  },
200
533
  "aria-label": "会话里程碑",
201
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
202
- style: {
203
- height: "100%",
204
- overflowY: "auto",
205
- display: "flex",
206
- flexDirection: "column",
207
- alignItems: "center",
208
- gap: DOT_GAP,
209
- padding: "6px 0",
210
- scrollbarWidth: "none"
211
- },
212
- children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
534
+ children: [
535
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
213
536
  type: "button",
537
+ "data-load-older": true,
538
+ "data-loading-older": loadingOlder ? "true" : void 0,
539
+ title: "加载更早消息",
540
+ "aria-label": "加载更早消息",
541
+ disabled: loadingOlder,
542
+ onClick: () => {
543
+ loadOlder();
544
+ },
214
545
  style: {
215
546
  width: DOT_HIT,
216
547
  height: DOT_HIT,
@@ -221,94 +552,198 @@ window.__ModuleLoader__.load({
221
552
  background: "transparent",
222
553
  border: "none",
223
554
  padding: 0,
224
- cursor: "pointer"
555
+ cursor: loadingOlder ? "default" : "pointer",
556
+ color: loadingOlder ? "#5a6375" : "#8b96ab",
557
+ fontSize: 11,
558
+ lineHeight: 1,
559
+ letterSpacing: 1
225
560
  },
226
- onMouseEnter: (e) => {
227
- const rect = e.currentTarget.getBoundingClientRect();
228
- setHover({
229
- ...buildHover(mark, i),
230
- top: rect.top + rect.height / 2
231
- });
561
+ children: "···"
562
+ }),
563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
564
+ panelTop: railBox.top,
565
+ panelRight: railBox.right + DOT_HIT + 8,
566
+ query: search.query,
567
+ panelOpen: search.panelOpen,
568
+ matches: matches.length,
569
+ total: marks.length,
570
+ onToggle: () => setSearch((s) => ({
571
+ ...s,
572
+ panelOpen: !s.panelOpen
573
+ })),
574
+ onQueryChange: updateQuery,
575
+ onSearchKeyDown,
576
+ onClear: clearSearch
577
+ }),
578
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
579
+ style: {
580
+ flex: 1,
581
+ minHeight: 0,
582
+ overflowY: "auto",
583
+ display: "flex",
584
+ flexDirection: "column",
585
+ alignItems: "center",
586
+ gap: DOT_GAP,
587
+ padding: "6px 0",
588
+ scrollbarWidth: "none"
232
589
  },
233
- onMouseLeave: () => setHover(null),
234
- onClick: () => jump(mark.key),
235
- "aria-label": `跳转到第 ${i + 1} 条消息`,
236
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
237
- width: DOT_SIZE,
238
- height: DOT_SIZE,
239
- borderRadius: "50%",
240
- background: dotColor(i, marks.length),
241
- boxShadow: hover?.mark.key === mark.key ? "0 0 0 3px rgba(77, 124, 254, 0.35)" : "none",
242
- transition: "transform 120ms ease",
243
- transform: hover?.mark.key === mark.key ? "scale(1.35)" : "none"
244
- } })
245
- }, mark.key))
246
- }), hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
247
- style: {
248
- position: "fixed",
249
- right: railBox.right + DOT_HIT + 8,
250
- top: hover.top,
251
- transform: "translateY(-50%)",
252
- maxWidth: 300,
253
- minWidth: 180,
254
- padding: "8px 12px",
255
- background: "rgba(20, 24, 32, 0.96)",
256
- color: "#e6e8ee",
257
- borderRadius: 8,
258
- fontSize: 12,
259
- lineHeight: 1.6,
260
- whiteSpace: "pre-wrap",
261
- wordBreak: "break-word",
262
- boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
263
- zIndex: 101,
264
- pointerEvents: "none"
265
- },
266
- children: [
267
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
268
- style: {
269
- display: "flex",
270
- gap: 8,
271
- color: "#9aa4b8",
272
- fontSize: 11,
273
- marginBottom: 4
274
- },
275
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
276
- " ",
277
- hover.index + 1,
278
- " / ",
279
- hover.total,
280
- " 条"
281
- ] }), hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel })]
282
- }),
283
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
284
- style: { color: "#c7cede" },
285
- children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
286
- }),
287
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
288
- style: {
289
- display: "flex",
290
- flexWrap: "wrap",
291
- gap: 8,
292
- color: "#8b96ab",
293
- fontSize: 11,
294
- marginTop: 4
295
- },
296
- children: [
297
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatRelativeTime(hover.mark.time) }),
298
- hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["用时 ", hover.durationLabel] }),
299
- hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
300
- hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["首字 ", hover.ttftLabel] }),
301
- hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
302
- ]
590
+ children: marks.map((mark, i) => {
591
+ const dotState = markState({
592
+ key: mark.key,
593
+ hasQuery,
594
+ isMatch: matches.includes(i),
595
+ isActive: i === activeMarkIndex,
596
+ isCurrent: !hasQuery && mark.key === currentKey
597
+ });
598
+ const isHovered = hover?.mark.key === mark.key;
599
+ const boxShadow = isHovered ? "0 0 0 3px rgba(77, 124, 254, 0.35)" : dotState === "active" ? "0 0 0 3px rgba(255, 255, 255, 0.9)" : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : "none";
600
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
601
+ type: "button",
602
+ style: {
603
+ width: DOT_HIT,
604
+ height: DOT_HIT,
605
+ flexShrink: 0,
606
+ display: "flex",
607
+ alignItems: "center",
608
+ justifyContent: "center",
609
+ background: "transparent",
610
+ border: "none",
611
+ padding: 0,
612
+ cursor: "pointer"
613
+ },
614
+ onMouseEnter: (e) => {
615
+ const rect = e.currentTarget.getBoundingClientRect();
616
+ setHover({
617
+ ...buildHover(mark, i),
618
+ top: rect.top + rect.height / 2
619
+ });
620
+ },
621
+ onMouseLeave: () => setHover(null),
622
+ onClick: () => jump(mark.key),
623
+ "aria-label": `跳转到第 ${i + 1} 条消息`,
624
+ "aria-current": dotState === "active" ? "true" : void 0,
625
+ "data-current": dotState === "current" ? "true" : void 0,
626
+ "data-dimmed": dotState === "dimmed" ? "true" : void 0,
627
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
628
+ width: DOT_SIZE,
629
+ height: DOT_SIZE,
630
+ borderRadius: "50%",
631
+ background: dotColor(i, marks.length),
632
+ boxShadow,
633
+ transition: "transform 120ms ease, opacity 120ms ease",
634
+ transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
635
+ opacity: isHovered || dotState !== "dimmed" ? 1 : .22
636
+ } })
637
+ }, mark.key);
303
638
  })
304
- ]
305
- })]
639
+ }),
640
+ hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
641
+ style: {
642
+ position: "fixed",
643
+ right: railBox.right + DOT_HIT + 8,
644
+ top: hover.top,
645
+ transform: "translateY(-50%)",
646
+ maxWidth: 300,
647
+ minWidth: 180,
648
+ padding: "8px 12px",
649
+ background: "rgba(20, 24, 32, 0.96)",
650
+ color: "#e6e8ee",
651
+ borderRadius: 8,
652
+ fontSize: 12,
653
+ lineHeight: 1.6,
654
+ whiteSpace: "pre-wrap",
655
+ wordBreak: "break-word",
656
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
657
+ zIndex: 101,
658
+ pointerEvents: "none"
659
+ },
660
+ children: [
661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
662
+ style: {
663
+ display: "flex",
664
+ gap: 8,
665
+ color: "#9aa4b8",
666
+ fontSize: 11,
667
+ marginBottom: 4
668
+ },
669
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
670
+ "第 ",
671
+ hover.index + 1,
672
+ " / ",
673
+ hover.total,
674
+ " 条"
675
+ ] }), hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel })]
676
+ }),
677
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
678
+ style: { color: "#c7cede" },
679
+ children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
680
+ }),
681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
682
+ style: {
683
+ display: "flex",
684
+ flexWrap: "wrap",
685
+ gap: 8,
686
+ color: "#8b96ab",
687
+ fontSize: 11,
688
+ marginTop: 4
689
+ },
690
+ children: [
691
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatRelativeTime(hover.mark.time) }),
692
+ hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["用时 ", hover.durationLabel] }),
693
+ hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
694
+ hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["首字 ", hover.ttftLabel] }),
695
+ hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
696
+ ]
697
+ })
698
+ ]
699
+ }),
700
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
701
+ "data-window-hint": true,
702
+ style: {
703
+ position: "absolute",
704
+ bottom: 6,
705
+ right: "100%",
706
+ marginRight: 8,
707
+ whiteSpace: "nowrap",
708
+ fontSize: 10,
709
+ lineHeight: 1,
710
+ color: "rgba(139, 150, 171, 0.9)",
711
+ pointerEvents: "none",
712
+ userSelect: "none"
713
+ },
714
+ children: [
715
+ "已显示 ",
716
+ marks.length,
717
+ " 条 · 还有更早"
718
+ ]
719
+ })
720
+ ]
306
721
  });
307
722
  }
308
723
  //#endregion
724
+ //#region src/client/railInject.ts
725
+ /**
726
+ * Wrap a session-bound `loadOlder` call into a safe action closure.
727
+ *
728
+ * - Missing binding: resolves (never throws on an unlisted/unscoped session).
729
+ * - Bound session: delegates to `session.loadOlder()`; a rejection propagates
730
+ * unchanged so callers can surface the transport error.
731
+ *
732
+ * @param sessions - the injected sessions service (`ctx.sessions`).
733
+ * @param sessionId - the session the rail is scoped to.
734
+ * @returns an action that loads the previous message page for that session.
735
+ */
736
+ function createLoadOlder(sessions, sessionId) {
737
+ return async () => {
738
+ const binding = sessions.binding(sessionId);
739
+ if (binding === void 0) return;
740
+ await binding.session.loadOlder();
741
+ };
742
+ }
743
+ //#endregion
309
744
  //#region src/client/index.ts
310
745
  /** Required services (cordis fiber inject). */
311
- const inject = ["slots"];
746
+ const inject = ["slots", "sessions"];
312
747
  /**
313
748
  * Register the overlay and rail once their slot declarations are on the
314
749
  * ledger. The overlay registers directly against the shipped shell.overlay
@@ -326,7 +761,10 @@ window.__ModuleLoader__.load({
326
761
  scope: "session"
327
762
  } }
328
763
  }, MilestoneOverlay));
329
- ctx.slots.inject("milestone.rail", () => ctx.slots.register({ name: "milestone.rail" }, MilestoneRail));
764
+ ctx.slots.inject("milestone.rail", () => ctx.slots.register({
765
+ name: "milestone.rail",
766
+ inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
767
+ }, MilestoneRail));
330
768
  }
331
769
  //#endregion
332
770
  exports.apply = apply;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-milestone",
3
- "version": "0.2.1",
4
- "description": "Git-style milestone timeline for DeepSeek Harness: a fixed-pitch dot rail beside the conversation — hover for rich metadata, click to jump to any message.",
3
+ "version": "0.3.1",
4
+ "description": "Git-style milestone timeline for DeepSeek Harness: hover for metadata, click to jump to any message. 会话里程碑导航条:圆点时间线,定位并跳转到每条提问。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -26,6 +26,9 @@
26
26
  "build": "tsdown",
27
27
  "watch": "tsdown --watch",
28
28
  "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "test:surface": "playwright test",
29
32
  "prepublishOnly": "pnpm run build"
30
33
  },
31
34
  "license": "MIT",
@@ -53,9 +56,16 @@
53
56
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
54
57
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
55
58
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
59
+ "@playwright/test": "^1.62.1",
60
+ "@testing-library/jest-dom": "^7.0.1",
61
+ "@testing-library/react": "^16.3.2",
62
+ "@testing-library/user-event": "^14.6.4",
63
+ "@types/node": "^26.2.0",
56
64
  "@types/react": "~18.3.1",
65
+ "jsdom": "^30.0.1",
57
66
  "react": "^18.2.0",
58
67
  "tsdown": "0.22.2",
59
- "typescript": "^6.0.3"
68
+ "typescript": "^6.0.3",
69
+ "vitest": "^4.1.10"
60
70
  }
61
71
  }