dsh-milestone 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/lib/client.js +470 -85
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,6 +33,9 @@
33
33
  - **站内搜索** —— 搜索框过滤圆点,匹配的是**完整消息内容**(不是 80 字摘要),实时显示命中数 N/M,回车跳到下一个匹配,Esc 一键清空。
34
34
  - **当前位置高亮** —— 滚动会话时,离你视口最近的那条提问会亮起白环,永远知道「读到哪了」。
35
35
  - **加载更早** —— 历史没加载完时,顶部出现「···」按钮,点一下继续加载,并提示当前已显示多少条。
36
+ - **收藏书签** —— 悬停任意圆点可点星收藏,刷新后仍保留;顶部「★」一键只看收藏,把一次性跳转变反复回访。
37
+ - **键盘导航** —— 里程碑条是一个焦点组件:↑↓ 移动、回车跳转、Home/End 首尾,全程不用鼠标。
38
+ - **状态徽章** —— 圆点自动标出轮次健康状态:出错红环、达到上限黄环、重试橙环、运行中/等待输入蓝/黄脉冲。
36
39
  - **固定间距** —— 圆点**等距排列**,不随对话长度挤压变形,永远点得准。
37
40
  - **蓝色渐变** —— 最新最深、最早最浅,一眼看清提问的先后顺序,像 Git 提交图。
38
41
  - **滚轮滑动** —— 长会话圆点超出可视区时,鼠标在里程碑条上滚轮即可滑动选点。
@@ -79,7 +82,8 @@ shell.overlay (root scope)
79
82
  ```
80
83
 
81
84
  - **注入点**:`shell.overlay` —— 全框架浮动层,附加式、点击穿透,不影响任何现有 UI。
82
- - **数据源**:`chat.order` + `chat.nodes`(user 消息)+ `chat.timeline`(turn 元数据)+ `hasMore`/`loadingOlder`(分页状态)+ `loadOlder`(经 inject face 注入)。
85
+ - **数据源**:`chat.order` + `chat.nodes`(user 消息 + `turn-error`/`turn-max-tokens`/`model-retry` 节点)+ `chat.timeline`(turn 元数据)+ `hasMore`/`loadingOlder`(分页)+ `running`/`pending`(徽章)+ `loadOlder`(inject face)。
86
+ - **书签持久化**:harness `store.persist`(每会话 localStorage,key `dsh-milestone.bookmarks.<sessionId>`),经 `defineStore` 引擎读写。
83
87
  - **跳转**:DOM 锚点 `data-chat-anchor-key`,`scrollIntoView` 平滑定位。
84
88
  - **纯函数**:搜索过滤 / 位置计算 / 圆点状态都在 `rail-logic.ts` 纯函数里,单测覆盖。
85
89
 
@@ -87,6 +91,9 @@ shell.overlay (root scope)
87
91
 
88
92
  - 搜索范围 = 当前已加载的消息窗口(初始 50 条;点顶部「···」加载更早,更早的历史需先加载进来才能被搜到)。
89
93
  - TTFT / tokens/秒 依赖 turn 位置数据,窗口外或未完成的 turn 不显示(自动隐藏)。
94
+ - 徽章的瞬态状态(运行中/等待输入)只点亮**最新一条可见提问**——若运行中/等待输入的轮次其提问在窗口外,则无脉冲。
95
+ - 书签按**会话**隔离(不跨会话共享)。
96
+ - 尚无全局快捷键聚焦里程碑条(需 Tab 键切换到)。
90
97
 
91
98
  ## License
92
99
 
package/lib/client.js CHANGED
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let react = require("react");
9
+ let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
9
10
  //#region src/client/MilestoneOverlay.tsx
10
11
  /**
11
12
  * @param props - runtime share (root kit) + the narrowed renderSlot and the
@@ -18,6 +19,139 @@ window.__ModuleLoader__.load({
18
19
  });
19
20
  }
20
21
  //#endregion
22
+ //#region src/client/badge-logic.ts
23
+ /**
24
+ * Derive the badge for one mark.
25
+ *
26
+ * Precedence: error > max-tokens > retry > running > awaiting. Node-derived
27
+ * badges ('turn-error' -> error, 'turn-max-tokens' -> max-tokens,
28
+ * 'model-retry' -> retry) fire regardless of `lastMark`; the transient badges
29
+ * (running, awaiting) only apply to the newest mark. Callers must already
30
+ * exclude cancelled retries — a bare 'model-retry' kind is treated as retry.
31
+ *
32
+ * @param input - the mark's snapshot signals.
33
+ * @returns the winning badge kind, or null when no signal applies.
34
+ */
35
+ function deriveBadge(input) {
36
+ if (input.nodeKinds.includes("turn-error")) return "error";
37
+ if (input.nodeKinds.includes("turn-max-tokens")) return "max-tokens";
38
+ if (input.nodeKinds.includes("model-retry")) return "retry";
39
+ if (input.lastMark) {
40
+ if (input.running) return "running";
41
+ if (input.awaitingInput) return "awaiting";
42
+ }
43
+ return null;
44
+ }
45
+ /** Ring colors and pulse flag per badge kind; running/awaiting pulse. */
46
+ const RING_STYLES = {
47
+ error: {
48
+ color: "#ef4444",
49
+ pulse: false
50
+ },
51
+ "max-tokens": {
52
+ color: "#f59e0b",
53
+ pulse: false
54
+ },
55
+ retry: {
56
+ color: "#f97316",
57
+ pulse: false
58
+ },
59
+ running: {
60
+ color: "#4d7cfe",
61
+ pulse: true
62
+ },
63
+ awaiting: {
64
+ color: "#f59e0b",
65
+ pulse: true
66
+ }
67
+ };
68
+ /**
69
+ * Style tokens for a badge kind.
70
+ * @param badge - the derived badge kind.
71
+ * @returns the ring color and whether the dot should pulse.
72
+ */
73
+ function badgeRingStyle(badge) {
74
+ return RING_STYLES[badge];
75
+ }
76
+ //#endregion
77
+ //#region src/client/bookmark-logic.ts
78
+ /**
79
+ * Pure bookmark logic for the milestone rail: membership, immutable
80
+ * append/remove toggling, bookmark filtering of a mark list, and count.
81
+ *
82
+ * All functions are side-effect free (no React, no DOM) so the rail component
83
+ * can consume them directly and tests can exercise them in isolation. The
84
+ * persisted store engine lives in bookmarkStore.ts; this module only shapes
85
+ * values.
86
+ */
87
+ /**
88
+ * Whether a key is currently bookmarked.
89
+ * @param keys - the bookmark key list (in toggle order).
90
+ * @param key - the key to look up.
91
+ * @returns true when the key is present.
92
+ */
93
+ function isBookmarked(keys, key) {
94
+ return keys.includes(key);
95
+ }
96
+ /**
97
+ * Immutable toggle: append the key when it is not bookmarked, remove it when
98
+ * it is. Never mutates the input; returns a fresh list (order preserved).
99
+ * @param keys - the bookmark key list (in toggle order).
100
+ * @param key - the key to flip.
101
+ * @returns a new list with the key toggled.
102
+ */
103
+ function toggleKey(keys, key) {
104
+ return isBookmarked(keys, key) ? keys.filter((k) => k !== key) : [...keys, key];
105
+ }
106
+ /**
107
+ * Filter a mark list down to the bookmarked marks.
108
+ * @param marks - marks in rail order (only `key` is consulted).
109
+ * @param bookmarked - the bookmark key list.
110
+ * @returns `visible` (ascending indices of marks whose key is bookmarked;
111
+ * empty whenever there are no bookmarks) and `isFiltered` (true exactly when
112
+ * any bookmark exists — callers treat it as "filter active").
113
+ */
114
+ function filterByBookmarks(marks, bookmarked) {
115
+ if (bookmarked.length === 0) return {
116
+ visible: [],
117
+ isFiltered: false
118
+ };
119
+ const set = new Set(bookmarked);
120
+ return {
121
+ visible: marks.reduce((acc, mark, i) => {
122
+ if (set.has(mark.key)) acc.push(i);
123
+ return acc;
124
+ }, []),
125
+ isFiltered: true
126
+ };
127
+ }
128
+ //#endregion
129
+ //#region src/client/rail-keyboard.ts
130
+ /**
131
+ * Pure roving-tabindex index math for the milestone rail.
132
+ *
133
+ * The dots list becomes a single roving-tabindex widget (ArrowUp/Down moves
134
+ * focus, Home/End jumps to first/last). This module only owns the pure index
135
+ * arithmetic; the widget wiring lives in the component.
136
+ */
137
+ /**
138
+ * Move `current` by `delta` (1 = forward, -1 = backward), wrapping around
139
+ * `[0, count - 1]`. Returns `-1` when there are no focusable dots.
140
+ */
141
+ function nextFocusIndex(current, count, delta) {
142
+ if (count <= 0) return -1;
143
+ return (current + delta + count) % count;
144
+ }
145
+ /**
146
+ * Clamp `current` into `[0, count - 1]` — e.g. when the visible dot list
147
+ * shrinks and the focused index no longer exists. Returns `-1` when there
148
+ * are no focusable dots.
149
+ */
150
+ function clampIndex(current, count) {
151
+ if (count <= 0) return -1;
152
+ return Math.min(Math.max(current, 0), count - 1);
153
+ }
154
+ //#endregion
21
155
  //#region src/client/rail-logic.ts
22
156
  /**
23
157
  * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
@@ -233,6 +367,123 @@ window.__ModuleLoader__.load({
233
367
  })] });
234
368
  }
235
369
  //#endregion
370
+ //#region src/client/MilestoneRailTooltip.tsx
371
+ /**
372
+ * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
373
+ */
374
+ function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onMouseEnter, onMouseLeave, panelRight }) {
375
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
376
+ onMouseEnter,
377
+ onMouseLeave,
378
+ style: {
379
+ position: "fixed",
380
+ right: panelRight,
381
+ top: hover.top,
382
+ transform: "translateY(-50%)",
383
+ maxWidth: 300,
384
+ minWidth: 180,
385
+ padding: "8px 12px",
386
+ background: "rgba(20, 24, 32, 0.96)",
387
+ color: "#e6e8ee",
388
+ borderRadius: 8,
389
+ fontSize: 12,
390
+ lineHeight: 1.6,
391
+ whiteSpace: "pre-wrap",
392
+ wordBreak: "break-word",
393
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
394
+ zIndex: 101,
395
+ pointerEvents: "auto"
396
+ },
397
+ children: [
398
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
399
+ style: {
400
+ display: "flex",
401
+ alignItems: "center",
402
+ gap: 8,
403
+ color: "#9aa4b8",
404
+ fontSize: 11,
405
+ marginBottom: 4
406
+ },
407
+ children: [
408
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
409
+ "第 ",
410
+ hover.index + 1,
411
+ " / ",
412
+ hover.total,
413
+ " 条"
414
+ ] }),
415
+ hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
417
+ type: "button",
418
+ "data-star": true,
419
+ "aria-label": "收藏此消息",
420
+ "aria-pressed": bookmarked,
421
+ "data-starred": bookmarked ? "true" : void 0,
422
+ onClick: (e) => {
423
+ e.stopPropagation();
424
+ onToggleBookmark();
425
+ },
426
+ style: {
427
+ marginLeft: "auto",
428
+ width: 22,
429
+ height: 22,
430
+ flexShrink: 0,
431
+ display: "flex",
432
+ alignItems: "center",
433
+ justifyContent: "center",
434
+ background: "transparent",
435
+ border: "none",
436
+ padding: 0,
437
+ cursor: "pointer",
438
+ color: bookmarked ? "#ffd166" : "#8b96ab"
439
+ },
440
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
441
+ width: "13",
442
+ height: "13",
443
+ viewBox: "0 0 24 24",
444
+ fill: bookmarked ? "currentColor" : "none",
445
+ stroke: "currentColor",
446
+ strokeWidth: "2",
447
+ strokeLinejoin: "round",
448
+ "aria-hidden": "true",
449
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
450
+ })
451
+ })
452
+ ]
453
+ }),
454
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
455
+ style: { color: "#c7cede" },
456
+ children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
457
+ }),
458
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
459
+ style: {
460
+ display: "flex",
461
+ flexWrap: "wrap",
462
+ gap: 8,
463
+ color: "#8b96ab",
464
+ fontSize: 11,
465
+ marginTop: 4
466
+ },
467
+ children: [
468
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatRelativeTime(hover.mark.time) }),
469
+ hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["用时 ", hover.durationLabel] }),
470
+ hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
471
+ hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["首字 ", hover.ttftLabel] }),
472
+ hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
473
+ ]
474
+ })
475
+ ]
476
+ });
477
+ }
478
+ /** Relative wall-clock label for a Unix-epoch-ms timestamp. */
479
+ function formatRelativeTime(time) {
480
+ const diff = Date.now() - time;
481
+ if (diff < 6e4) return "刚刚";
482
+ if (diff < 36e5) return `${Math.floor(diff / 6e4)} 分钟前`;
483
+ if (diff < 864e5) return `${Math.floor(diff / 36e5)} 小时前`;
484
+ return `${Math.floor(diff / 864e5)} 天前`;
485
+ }
486
+ //#endregion
236
487
  //#region src/client/useCurrentAnchor.ts
237
488
  /**
238
489
  * useCurrentAnchor: tracks which user-message row sits at/just above the
@@ -332,6 +583,21 @@ window.__ModuleLoader__.load({
332
583
  /** Minimum user messages before the rail adds value. */
333
584
  const MIN_MARKS = 2;
334
585
  const PREVIEW_LENGTH = 80;
586
+ /** Stable no-bookmarks fallback for render paths without the store seat. */
587
+ const NO_BOOKMARKS = [];
588
+ /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
589
+ const NO_KINDS = [];
590
+ /**
591
+ * Self-contained pulse keyframes for the transient badges (running/awaiting):
592
+ * an expanding currentColor ring on box-shadow plus an opacity beat, driven by
593
+ * `animation` on the badge ring span (kept in an inline <style> so the plugin
594
+ * stays zero-asset).
595
+ */
596
+ const BADGE_PULSE_CSS = `@keyframes milestone-badge-pulse {
597
+ 0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.85 }
598
+ 70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
599
+ 100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
600
+ }`;
335
601
  /** Visual dot diameter (px). */
336
602
  const DOT_SIZE = 12;
337
603
  /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
@@ -352,14 +618,6 @@ window.__ModuleLoader__.load({
352
618
  function extractPreview(content) {
353
619
  return extractText(content).slice(0, PREVIEW_LENGTH);
354
620
  }
355
- /** Relative wall-clock label for a Unix-epoch-ms timestamp. */
356
- function formatRelativeTime(time) {
357
- const diff = Date.now() - time;
358
- if (diff < 6e4) return "刚刚";
359
- if (diff < 36e5) return `${Math.floor(diff / 6e4)} 分钟前`;
360
- if (diff < 864e5) return `${Math.floor(diff / 36e5)} 小时前`;
361
- return `${Math.floor(diff / 864e5)} 天前`;
362
- }
363
621
  /** Compact duration label (ms). */
364
622
  function formatDuration(ms) {
365
623
  if (ms < 1e3) return `${ms}ms`;
@@ -385,14 +643,17 @@ window.__ModuleLoader__.load({
385
643
  return data.get("turn-tail");
386
644
  }
387
645
  /**
388
- * @param props - session standard kit (useSession, sessionId, useProjection).
646
+ * @param props - session standard kit (useSession, sessionId, useProjection),
647
+ * the injected loadOlder action, and the bookmarks store pair (useStore +
648
+ * actions, injected by the framework from the declared store seat).
389
649
  */
390
- function MilestoneRail({ useSession, loadOlder }) {
650
+ function MilestoneRail({ useSession, loadOlder, useStore, actions }) {
391
651
  const order = useSession((s) => s.chat.order);
392
652
  const nodes = useSession((s) => s.chat.nodes);
393
653
  const timeline = useSession((s) => s.chat.timeline);
394
654
  const hasMore = useSession((s) => s.hasMore);
395
655
  const loadingOlder = useSession((s) => s.loadingOlder);
656
+ const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
396
657
  const marks = (0, react.useMemo)(() => {
397
658
  const result = [];
398
659
  for (const key of order) {
@@ -411,6 +672,22 @@ window.__ModuleLoader__.load({
411
672
  }
412
673
  return result;
413
674
  }, [order, nodes]);
675
+ const kindsByTurn = (0, react.useMemo)(() => {
676
+ const result = /* @__PURE__ */ new Map();
677
+ for (const node of nodes.values()) {
678
+ if (node.kind !== "turn-error" && node.kind !== "turn-max-tokens" && node.kind !== "model-retry") continue;
679
+ if (node.kind === "model-retry") {
680
+ if (node.data?.retryState === "cancelled") continue;
681
+ }
682
+ if (node.location.kind !== "turn" && node.location.kind !== "step") continue;
683
+ const kinds = result.get(node.location.turn.turn) ?? [];
684
+ kinds.push(node.kind);
685
+ result.set(node.location.turn.turn, kinds);
686
+ }
687
+ return result;
688
+ }, [order, nodes]);
689
+ const running = useSession((s) => s.running);
690
+ const awaitingInput = useSession((s) => s.pending).length > 0;
414
691
  const [railBox, setRailBox] = (0, react.useState)(null);
415
692
  const [hover, setHover] = (0, react.useState)(null);
416
693
  const [search, setSearch] = (0, react.useState)({
@@ -418,8 +695,19 @@ window.__ModuleLoader__.load({
418
695
  activePos: 0,
419
696
  panelOpen: false
420
697
  });
698
+ const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
699
+ const [focusIndex, setFocusIndex] = (0, react.useState)(0);
700
+ const listRef = (0, react.useRef)(null);
421
701
  const currentKey = useCurrentAnchor(order);
422
- const { matches } = (0, react.useMemo)(() => filterMarks(marks, search.query), [marks, search.query]);
702
+ const displayMarks = (0, react.useMemo)(() => {
703
+ if (!bookmarksOnly) return marks;
704
+ return filterByBookmarks(marks, bookmarkedKeys).visible.map((i) => marks[i]);
705
+ }, [
706
+ bookmarksOnly,
707
+ marks,
708
+ bookmarkedKeys
709
+ ]);
710
+ const { matches } = (0, react.useMemo)(() => filterMarks(displayMarks, search.query), [displayMarks, search.query]);
423
711
  const hasQuery = search.query.trim() !== "";
424
712
  const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
425
713
  (0, react.useLayoutEffect)(() => {
@@ -446,6 +734,9 @@ window.__ModuleLoader__.load({
446
734
  window.removeEventListener("resize", compute);
447
735
  };
448
736
  }, [marks.length]);
737
+ (0, react.useLayoutEffect)(() => {
738
+ setFocusIndex((f) => clampIndex(f, displayMarks.length));
739
+ }, [displayMarks.length]);
449
740
  if (railBox === null || marks.length < MIN_MARKS) return null;
450
741
  const jump = (key) => {
451
742
  findRow(key)?.scrollIntoView({
@@ -482,12 +773,50 @@ window.__ModuleLoader__.load({
482
773
  ...s,
483
774
  activePos: next
484
775
  }));
485
- jump(marks[matches[next]].key);
776
+ jump(displayMarks[matches[next]].key);
486
777
  };
487
778
  const onSearchKeyDown = (e) => {
488
779
  if (e.key === "Enter") advanceMatch();
489
780
  if (e.key === "Escape") closeSearch();
490
781
  };
782
+ /** Focus the dot at `index` (no-op while the list is unmounted). */
783
+ const focusDotAt = (index) => {
784
+ listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
785
+ };
786
+ /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
787
+ const onListFocus = (e) => {
788
+ if (e.target !== e.currentTarget) return;
789
+ focusDotAt(clampIndex(focusIndex, displayMarks.length));
790
+ };
791
+ /**
792
+ * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
793
+ * jump to first/last. Enter/Space are deliberately NOT handled — the dots
794
+ * are real buttons, so native activation fires the jump click untouched
795
+ * (preventDefault here would swallow it).
796
+ */
797
+ const onListKeyDown = (e) => {
798
+ const count = displayMarks.length;
799
+ let next = null;
800
+ switch (e.key) {
801
+ case "ArrowDown":
802
+ next = nextFocusIndex(focusIndex, count, 1);
803
+ break;
804
+ case "ArrowUp":
805
+ next = nextFocusIndex(focusIndex, count, -1);
806
+ break;
807
+ case "Home":
808
+ next = 0;
809
+ break;
810
+ case "End":
811
+ next = count - 1;
812
+ break;
813
+ default: return;
814
+ }
815
+ e.preventDefault();
816
+ const target = clampIndex(next, count);
817
+ setFocusIndex(target);
818
+ focusDotAt(target);
819
+ };
491
820
  const buildHover = (mark, index) => {
492
821
  const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
493
822
  let durationLabel = null;
@@ -509,7 +838,7 @@ window.__ModuleLoader__.load({
509
838
  return {
510
839
  mark,
511
840
  index,
512
- total: marks.length,
841
+ total: displayMarks.length,
513
842
  turnLabel: mark.turn !== void 0 ? `第 ${mark.turn} 轮` : null,
514
843
  durationLabel,
515
844
  reasonLabel,
@@ -517,6 +846,18 @@ window.__ModuleLoader__.load({
517
846
  tpsLabel
518
847
  };
519
848
  };
849
+ /**
850
+ * T10: flip a mark's bookmark in the persisted store. The store action is
851
+ * the write path (the engine persists synchronously). The hover re-assert
852
+ * forces a re-render so the star reflects the toggled state — production
853
+ * re-renders through the framework's uSES-bound useStore; the component
854
+ * test harness injects an unsubscribed selector, so this local re-render is
855
+ * what syncs the DOM there. Both paths converge on the same fresh snapshot.
856
+ */
857
+ const onToggleBookmark = (key) => {
858
+ actions?.toggle(key);
859
+ setHover((h) => h === null ? h : { ...h });
860
+ };
520
861
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
521
862
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
522
863
  style: {
@@ -532,6 +873,7 @@ window.__ModuleLoader__.load({
532
873
  },
533
874
  "aria-label": "会话里程碑",
534
875
  children: [
876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
535
877
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
536
878
  type: "button",
537
879
  "data-load-older": true,
@@ -560,13 +902,45 @@ window.__ModuleLoader__.load({
560
902
  },
561
903
  children: "···"
562
904
  }),
905
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
906
+ type: "button",
907
+ "data-bookmarks-toggle": true,
908
+ "aria-label": "只看收藏",
909
+ "aria-pressed": bookmarksOnly,
910
+ "data-active": bookmarksOnly ? "true" : void 0,
911
+ onClick: () => setBookmarksOnly((v) => !v),
912
+ style: {
913
+ width: DOT_HIT,
914
+ height: DOT_HIT,
915
+ flexShrink: 0,
916
+ display: "flex",
917
+ alignItems: "center",
918
+ justifyContent: "center",
919
+ background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
920
+ border: "none",
921
+ padding: 0,
922
+ cursor: "pointer",
923
+ color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
924
+ },
925
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
926
+ width: "13",
927
+ height: "13",
928
+ viewBox: "0 0 24 24",
929
+ fill: bookmarksOnly ? "currentColor" : "none",
930
+ stroke: "currentColor",
931
+ strokeWidth: "2",
932
+ strokeLinejoin: "round",
933
+ "aria-hidden": "true",
934
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
935
+ })
936
+ }),
563
937
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
564
938
  panelTop: railBox.top,
565
939
  panelRight: railBox.right + DOT_HIT + 8,
566
940
  query: search.query,
567
941
  panelOpen: search.panelOpen,
568
942
  matches: matches.length,
569
- total: marks.length,
943
+ total: displayMarks.length,
570
944
  onToggle: () => setSearch((s) => ({
571
945
  ...s,
572
946
  panelOpen: !s.panelOpen
@@ -576,6 +950,12 @@ window.__ModuleLoader__.load({
576
950
  onClear: clearSearch
577
951
  }),
578
952
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
953
+ ref: listRef,
954
+ "data-rail-list": true,
955
+ tabIndex: 0,
956
+ "aria-label": "会话里程碑列表",
957
+ onFocus: onListFocus,
958
+ onKeyDown: onListKeyDown,
579
959
  style: {
580
960
  flex: 1,
581
961
  minHeight: 0,
@@ -587,7 +967,8 @@ window.__ModuleLoader__.load({
587
967
  padding: "6px 0",
588
968
  scrollbarWidth: "none"
589
969
  },
590
- children: marks.map((mark, i) => {
970
+ children: displayMarks.map((mark, i) => {
971
+ const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
591
972
  const dotState = markState({
592
973
  key: mark.key,
593
974
  hasQuery,
@@ -597,6 +978,13 @@ window.__ModuleLoader__.load({
597
978
  });
598
979
  const isHovered = hover?.mark.key === mark.key;
599
980
  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";
981
+ const badge = deriveBadge({
982
+ nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
983
+ lastMark: i === displayMarks.length - 1,
984
+ running,
985
+ awaitingInput
986
+ });
987
+ const ringStyle = badge === null ? null : badgeRingStyle(badge);
600
988
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
601
989
  type: "button",
602
990
  style: {
@@ -618,84 +1006,50 @@ window.__ModuleLoader__.load({
618
1006
  top: rect.top + rect.height / 2
619
1007
  });
620
1008
  },
621
- onMouseLeave: () => setHover(null),
622
1009
  onClick: () => jump(mark.key),
1010
+ "data-rail-dot": true,
1011
+ tabIndex: focusIndex === i ? 0 : -1,
1012
+ onFocus: () => setFocusIndex(i),
623
1013
  "aria-label": `跳转到第 ${i + 1} 条消息`,
624
1014
  "aria-current": dotState === "active" ? "true" : void 0,
625
1015
  "data-current": dotState === "current" ? "true" : void 0,
626
1016
  "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
- } })
1017
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1018
+ style: {
1019
+ position: "relative",
1020
+ width: DOT_SIZE,
1021
+ height: DOT_SIZE,
1022
+ borderRadius: "50%",
1023
+ background: dotColor(i, marks.length),
1024
+ boxShadow,
1025
+ transition: "transform 120ms ease, opacity 120ms ease",
1026
+ transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
1027
+ opacity: isHovered || dotState !== "dimmed" ? 1 : .22
1028
+ },
1029
+ "data-bookmarked": bookmarked ? "true" : void 0,
1030
+ children: ringStyle !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1031
+ "data-badge": badge,
1032
+ style: {
1033
+ position: "absolute",
1034
+ inset: -3,
1035
+ borderRadius: "50%",
1036
+ border: `2px solid ${ringStyle.color}`,
1037
+ color: ringStyle.color,
1038
+ pointerEvents: "none",
1039
+ animation: ringStyle.pulse ? "milestone-badge-pulse 1.4s ease-out infinite" : void 0
1040
+ }
1041
+ })
1042
+ })
637
1043
  }, mark.key);
638
1044
  })
639
1045
  }),
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
- ]
1046
+ hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
1047
+ panelRight: railBox.right + DOT_HIT + 8,
1048
+ hover,
1049
+ bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
1050
+ onToggleBookmark: () => onToggleBookmark(hover.mark.key),
1051
+ onMouseEnter: () => setHover((h) => h),
1052
+ onMouseLeave: () => setHover(null)
699
1053
  }),
700
1054
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
701
1055
  "data-window-hint": true,
@@ -721,6 +1075,36 @@ window.__ModuleLoader__.load({
721
1075
  });
722
1076
  }
723
1077
  //#endregion
1078
+ //#region src/client/bookmarkStore.ts
1079
+ /**
1080
+ * Persisted per-session bookmarks store for the milestone rail.
1081
+ *
1082
+ * A thin declarative shell over the harness snapshot-store engine: pure
1083
+ * draft-mutator actions, persisted to localStorage under the key
1084
+ * `dsh-milestone.bookmarks` (+ `.${scopeKey}` for session-scope instances,
1085
+ * resolved by the engine's `create(scopeKey)`). Consumers must call the
1086
+ * FACTORY (never a module-level handle — module-cache identity is a disguised
1087
+ * singleton across plugin reloads).
1088
+ */
1089
+ /**
1090
+ * Declare the bookmarks store handle. Returns a fresh handle per call; the
1091
+ * framework (or tests) create per-session instances via `create(scopeKey)`.
1092
+ */
1093
+ function createBookmarksStore() {
1094
+ return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
1095
+ init: () => ({ keys: [] }),
1096
+ persist: "dsh-milestone.bookmarks",
1097
+ actions: {
1098
+ toggle: (draft, key) => {
1099
+ draft.keys = toggleKey(draft.keys, key);
1100
+ },
1101
+ clear: (draft) => {
1102
+ draft.keys = [];
1103
+ }
1104
+ }
1105
+ });
1106
+ }
1107
+ //#endregion
724
1108
  //#region src/client/railInject.ts
725
1109
  /**
726
1110
  * Wrap a session-bound `loadOlder` call into a safe action closure.
@@ -763,6 +1147,7 @@ window.__ModuleLoader__.load({
763
1147
  }, MilestoneOverlay));
764
1148
  ctx.slots.inject("milestone.rail", () => ctx.slots.register({
765
1149
  name: "milestone.rail",
1150
+ store: createBookmarksStore,
766
1151
  inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
767
1152
  }, MilestoneRail));
768
1153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-milestone",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
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",