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.
- package/README.md +8 -1
- package/lib/client.js +470 -85
- 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
|
|
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
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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", {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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