dsh-milestone 0.3.1 → 0.5.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 +16 -2
  2. package/lib/client.js +1002 -123
  3. package/package.json +3 -1
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,281 @@ 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/clipboard-logic.ts
130
+ /**
131
+ * Clipboard helper for the milestone rail: copies text to the system
132
+ * clipboard via the async Clipboard API. Resolves false (never throws) when
133
+ * the API is unavailable or the write is rejected, so callers can treat the
134
+ * result as a plain boolean.
135
+ */
136
+ /**
137
+ * Copy text to the system clipboard.
138
+ * @param text - the text to copy.
139
+ * @returns a promise resolving to true when the clipboard write succeeded,
140
+ * false when the Clipboard API is unavailable or the write was rejected.
141
+ */
142
+ async function copyText(text) {
143
+ if (typeof navigator === "undefined" || navigator.clipboard?.writeText === void 0) return false;
144
+ try {
145
+ await navigator.clipboard.writeText(text);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ //#endregion
152
+ //#region src/client/label-logic.ts
153
+ const MINUTE_MS = 6e4;
154
+ const HOUR_MS = 36e5;
155
+ const DAY_MS = 864e5;
156
+ /**
157
+ * Bucket an elapsed duration into a relative-time label.
158
+ *
159
+ * Buckets on `now - time` in milliseconds: below 60s -> justNow (n=0), below
160
+ * 3600s -> minutes, below 86400s -> hours, otherwise days. `n` is the whole
161
+ * count of the bucket unit (floor). Deterministic for a given `now`.
162
+ *
163
+ * @param time - the event timestamp in ms since epoch.
164
+ * @param now - the reference clock in ms since epoch.
165
+ * @returns the label key and bucket count.
166
+ */
167
+ function relativeTimeParts(time, now) {
168
+ const diff = now - time;
169
+ if (diff < MINUTE_MS) return {
170
+ key: "time.justNow",
171
+ n: 0
172
+ };
173
+ if (diff < HOUR_MS) return {
174
+ key: "time.minutes",
175
+ n: Math.floor(diff / MINUTE_MS)
176
+ };
177
+ if (diff < DAY_MS) return {
178
+ key: "time.hours",
179
+ n: Math.floor(diff / HOUR_MS)
180
+ };
181
+ return {
182
+ key: "time.days",
183
+ n: Math.floor(diff / DAY_MS)
184
+ };
185
+ }
186
+ /**
187
+ * Map a harness end-reason string to a stable i18n key.
188
+ * @param kind - the raw end-reason string (e.g. 'max-tokens').
189
+ * @returns the i18n key, or the raw kind unchanged when unknown.
190
+ */
191
+ function reasonKeyOf(kind) {
192
+ switch (kind) {
193
+ case "completed": return "reason.completed";
194
+ case "aborted": return "reason.aborted";
195
+ case "error": return "reason.error";
196
+ case "max-tokens": return "reason.maxTokens";
197
+ case "interrupted": return "reason.interrupted";
198
+ case "blocked": return "reason.blocked";
199
+ default: return kind;
200
+ }
201
+ }
202
+ //#endregion
203
+ //#region src/client/tooltip-logic.ts
204
+ const EMPTY_META = {
205
+ model: null,
206
+ purpose: null,
207
+ inputTokens: null,
208
+ outputTokens: null
209
+ };
210
+ /** True when the value is a plain (non-array, non-null) object. */
211
+ function isRecord(value) {
212
+ return value !== null && typeof value === "object" && !Array.isArray(value);
213
+ }
214
+ /**
215
+ * Decode a `usage` payload structurally: only numeric `inputTokens` /
216
+ * `outputTokens` survive; anything else (absent, malformed, wrong types)
217
+ * degrades to null — the boundary owns trust, the callers get plain numbers.
218
+ * @param usage - untrusted usage payload (typed `unknown` at runtime).
219
+ * @returns the token counts with null for every missing/malformed field.
220
+ */
221
+ function decodeUsage(usage) {
222
+ if (!isRecord(usage)) return {
223
+ inputTokens: null,
224
+ outputTokens: null
225
+ };
226
+ return {
227
+ inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
228
+ outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null
229
+ };
230
+ }
231
+ /** Resolve model/purpose from a request config, falling back to provenance. */
232
+ function metaFromRecord(record) {
233
+ return {
234
+ model: record.requestConfig?.model ?? record.provenance?.model ?? null,
235
+ purpose: record.requestConfig?.purpose ?? null,
236
+ ...decodeUsage(record.usage)
237
+ };
238
+ }
239
+ /**
240
+ * Derive the hover metadata for one turn. Sources, in priority order:
241
+ * 1. the `assistant-step` chat node(s) of the turn — their `data.finalNode`
242
+ * carries the recorded `requestConfig` / `provenance` / `usage`;
243
+ * 2. `trajectoryRequests` — the latest entry whose `turn` matches (used when
244
+ * no assistant-step node yields a model or purpose);
245
+ * 3. all-null when the turn is absent, no node matches, or everything is
246
+ * malformed. Never throws.
247
+ * @param nodes - stable per-key chat node reader (as exposed by the snapshot).
248
+ * @param locations - turn -> ordered node keys index.
249
+ * @param turn - owning turn; undefined yields all-null.
250
+ * @param trajectoryRequests - optional fallback request log.
251
+ * @returns the turn's metadata, null where unknown.
252
+ */
253
+ function deriveTurnMeta(nodes, locations, turn, trajectoryRequests) {
254
+ if (turn === void 0) return EMPTY_META;
255
+ for (const key of locations.getTurn(turn)) {
256
+ const node = nodes.get(key);
257
+ if (node === void 0 || node.kind !== "assistant-step") continue;
258
+ const finalNode = (isRecord(node.data) ? node.data : void 0)?.finalNode;
259
+ if (!isRecord(finalNode)) continue;
260
+ const meta = metaFromRecord(finalNode);
261
+ if (meta.model !== null || meta.purpose !== null) return meta;
262
+ }
263
+ if (trajectoryRequests !== void 0) {
264
+ let latest;
265
+ for (const request of trajectoryRequests) if (request.turn === turn) latest = request;
266
+ if (latest !== void 0) return metaFromRecord(latest);
267
+ }
268
+ return EMPTY_META;
269
+ }
270
+ //#endregion
271
+ //#region src/client/rail-keyboard.ts
272
+ /**
273
+ * Pure roving-tabindex index math for the milestone rail.
274
+ *
275
+ * The dots list becomes a single roving-tabindex widget (ArrowUp/Down moves
276
+ * focus, Home/End jumps to first/last). This module only owns the pure index
277
+ * arithmetic; the widget wiring lives in the component.
278
+ */
279
+ /**
280
+ * Move `current` by `delta` (1 = forward, -1 = backward), wrapping around
281
+ * `[0, count - 1]`. Returns `-1` when there are no focusable dots.
282
+ */
283
+ function nextFocusIndex(current, count, delta) {
284
+ if (count <= 0) return -1;
285
+ return (current + delta + count) % count;
286
+ }
287
+ /**
288
+ * Clamp `current` into `[0, count - 1]` — e.g. when the visible dot list
289
+ * shrinks and the focused index no longer exists. Returns `-1` when there
290
+ * are no focusable dots.
291
+ */
292
+ function clampIndex(current, count) {
293
+ if (count <= 0) return -1;
294
+ return Math.min(Math.max(current, 0), count - 1);
295
+ }
296
+ //#endregion
21
297
  //#region src/client/rail-logic.ts
22
298
  /**
23
299
  * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
@@ -108,17 +384,83 @@ window.__ModuleLoader__.load({
108
384
  return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
109
385
  }
110
386
  //#endregion
387
+ //#region src/client/turn-group-logic.ts
388
+ /**
389
+ * Partition consecutive marks by turn. Marks with the same numeric turn that
390
+ * appear one after another share a group; each mark with `turn === undefined`
391
+ * becomes its own singleton group with `turn: null`.
392
+ * @param marks - marks in rail order.
393
+ * @returns the groups, in original order, partitioning `marks` exactly.
394
+ */
395
+ function buildTurnGroups(marks) {
396
+ const groups = [];
397
+ let current;
398
+ for (const mark of marks) {
399
+ if (mark.turn === void 0) {
400
+ current = void 0;
401
+ groups.push({
402
+ turn: null,
403
+ marks: [mark]
404
+ });
405
+ continue;
406
+ }
407
+ if (current !== void 0 && current.turn === mark.turn) current.marks.push(mark);
408
+ else {
409
+ current = {
410
+ turn: mark.turn,
411
+ marks: [mark]
412
+ };
413
+ groups.push(current);
414
+ }
415
+ }
416
+ return groups;
417
+ }
418
+ /**
419
+ * Flatten groups into render items, collapsing collapsed turns to their last
420
+ * mark and reporting where separators belong.
421
+ * @param groups - groups from {@link buildTurnGroups} (they partition the
422
+ * original marks array in order, so a running count yields original indices).
423
+ * @param collapsed - turns whose group should collapse to its LAST mark.
424
+ * @returns `items` (one RenderItem per visible dot, in group order) and
425
+ * `separatorsAt` (the index in `items` before which a separator should be
426
+ * inserted at each non-first group boundary; never includes 0).
427
+ */
428
+ function buildRenderList(groups, collapsed) {
429
+ const items = [];
430
+ const separatorsAt = [];
431
+ let counter = 0;
432
+ for (const group of groups) {
433
+ const startIndex = items.length;
434
+ if (group.turn !== null && collapsed.has(group.turn) && group.marks.length > 1) {
435
+ const last = group.marks[group.marks.length - 1];
436
+ items.push({
437
+ mark: last,
438
+ displayIndex: counter + group.marks.length - 1
439
+ });
440
+ } else for (let i = 0; i < group.marks.length; i++) items.push({
441
+ mark: group.marks[i],
442
+ displayIndex: counter + i
443
+ });
444
+ counter += group.marks.length;
445
+ if (startIndex > 0) separatorsAt.push(startIndex);
446
+ }
447
+ return {
448
+ items,
449
+ separatorsAt
450
+ };
451
+ }
452
+ //#endregion
111
453
  //#region src/client/MilestoneRailSearch.tsx
112
454
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
113
455
  const DOT_HIT$1 = 22;
114
456
  /**
115
457
  * @param props - the search state slice plus the rail's event handlers.
116
458
  */
117
- function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear }) {
459
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
118
460
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
119
461
  type: "button",
120
462
  "data-search-toggle": true,
121
- "aria-label": "搜索消息",
463
+ "aria-label": t("search.label"),
122
464
  "aria-pressed": panelOpen,
123
465
  onClick: onToggle,
124
466
  style: {
@@ -170,8 +512,8 @@ window.__ModuleLoader__.load({
170
512
  },
171
513
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
172
514
  "data-rail-search": true,
173
- "aria-label": "搜索消息",
174
- placeholder: "搜索消息内容",
515
+ "aria-label": t("search.label"),
516
+ placeholder: t("search.placeholder"),
175
517
  value: query,
176
518
  onChange: (e) => onQueryChange(e.target.value),
177
519
  onKeyDown: onSearchKeyDown,
@@ -190,7 +532,7 @@ window.__ModuleLoader__.load({
190
532
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
191
533
  type: "button",
192
534
  "data-search-clear": true,
193
- "aria-label": "清空搜索",
535
+ "aria-label": t("search.clear"),
194
536
  onClick: onClear,
195
537
  style: {
196
538
  width: 22,
@@ -233,6 +575,207 @@ window.__ModuleLoader__.load({
233
575
  })] });
234
576
  }
235
577
  //#endregion
578
+ //#region src/client/MilestoneRailTooltip.tsx
579
+ /**
580
+ * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
581
+ */
582
+ function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onCopy, onFork, copied, forked, turnCollapsed, onToggleCollapse, onMouseEnter, onMouseLeave, panelRight, t }) {
583
+ const relativeTime = relativeTimeParts(hover.mark.time, Date.now());
584
+ const turn = hover.mark.turn;
585
+ const showCollapse = turn !== void 0 && hover.turnMarkCount !== null && hover.turnMarkCount > 1;
586
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
587
+ onMouseEnter,
588
+ onMouseLeave,
589
+ style: {
590
+ position: "fixed",
591
+ right: panelRight,
592
+ top: hover.top,
593
+ transform: "translateY(-50%)",
594
+ maxWidth: 300,
595
+ minWidth: 180,
596
+ padding: "8px 12px",
597
+ background: "rgba(20, 24, 32, 0.96)",
598
+ color: "#e6e8ee",
599
+ borderRadius: 8,
600
+ fontSize: 12,
601
+ lineHeight: 1.6,
602
+ whiteSpace: "pre-wrap",
603
+ wordBreak: "break-word",
604
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
605
+ zIndex: 101,
606
+ pointerEvents: "auto"
607
+ },
608
+ children: [
609
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
610
+ style: {
611
+ display: "flex",
612
+ alignItems: "center",
613
+ flexWrap: "wrap",
614
+ gap: 8,
615
+ color: "#9aa4b8",
616
+ fontSize: 11,
617
+ marginBottom: 4
618
+ },
619
+ children: [
620
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pos.of", {
621
+ n: hover.index + 1,
622
+ m: hover.total
623
+ }) }),
624
+ hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
625
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
626
+ type: "button",
627
+ "data-star": true,
628
+ "aria-label": t("bookmark.star"),
629
+ "aria-pressed": bookmarked,
630
+ "data-starred": bookmarked ? "true" : void 0,
631
+ onClick: (e) => {
632
+ e.stopPropagation();
633
+ onToggleBookmark();
634
+ },
635
+ style: {
636
+ marginLeft: "auto",
637
+ width: 22,
638
+ height: 22,
639
+ flexShrink: 0,
640
+ display: "flex",
641
+ alignItems: "center",
642
+ justifyContent: "center",
643
+ background: "transparent",
644
+ border: "none",
645
+ padding: 0,
646
+ cursor: "pointer",
647
+ color: bookmarked ? "#ffd166" : "#8b96ab"
648
+ },
649
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
650
+ width: "13",
651
+ height: "13",
652
+ viewBox: "0 0 24 24",
653
+ fill: bookmarked ? "currentColor" : "none",
654
+ stroke: "currentColor",
655
+ strokeWidth: "2",
656
+ strokeLinejoin: "round",
657
+ "aria-hidden": "true",
658
+ 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" })
659
+ })
660
+ }),
661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
662
+ type: "button",
663
+ "data-copy-message": true,
664
+ "data-copied": copied ? "true" : void 0,
665
+ onClick: (e) => {
666
+ e.stopPropagation();
667
+ onCopy(hover.mark);
668
+ },
669
+ style: {
670
+ flexShrink: 0,
671
+ display: "flex",
672
+ alignItems: "center",
673
+ justifyContent: "center",
674
+ background: "transparent",
675
+ border: "none",
676
+ padding: "2px 6px",
677
+ cursor: "pointer",
678
+ whiteSpace: "nowrap",
679
+ color: copied ? "#7ee2a8" : "#8b96ab"
680
+ },
681
+ children: t("copy.message")
682
+ }),
683
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
684
+ type: "button",
685
+ "data-fork-here": true,
686
+ "data-forked": forked ? "true" : void 0,
687
+ onClick: (e) => {
688
+ e.stopPropagation();
689
+ onFork(hover.mark);
690
+ },
691
+ style: {
692
+ flexShrink: 0,
693
+ display: "flex",
694
+ alignItems: "center",
695
+ justifyContent: "center",
696
+ background: "transparent",
697
+ border: "none",
698
+ padding: "2px 6px",
699
+ cursor: "pointer",
700
+ whiteSpace: "nowrap",
701
+ color: forked ? "#7ee2a8" : "#8b96ab"
702
+ },
703
+ children: t("fork.here")
704
+ }),
705
+ showCollapse && turn !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
706
+ type: "button",
707
+ "data-toggle-collapse": true,
708
+ "aria-pressed": turnCollapsed,
709
+ "data-collapsed": turnCollapsed ? "true" : void 0,
710
+ onClick: (e) => {
711
+ e.stopPropagation();
712
+ onToggleCollapse(turn);
713
+ },
714
+ style: {
715
+ flexShrink: 0,
716
+ display: "flex",
717
+ alignItems: "center",
718
+ justifyContent: "center",
719
+ background: "transparent",
720
+ border: "none",
721
+ padding: "2px 6px",
722
+ cursor: "pointer",
723
+ whiteSpace: "nowrap",
724
+ color: turnCollapsed ? "#7ee2a8" : "#8b96ab"
725
+ },
726
+ children: turnCollapsed ? t("expand.turn") : t("collapse.turn")
727
+ })
728
+ ]
729
+ }),
730
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
731
+ style: { color: "#c7cede" },
732
+ children: hover.mark.preview !== "" ? hover.mark.preview : t("no.text")
733
+ }),
734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
735
+ style: {
736
+ display: "flex",
737
+ flexWrap: "wrap",
738
+ gap: 8,
739
+ color: "#8b96ab",
740
+ fontSize: 11,
741
+ marginTop: 4
742
+ },
743
+ children: [
744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(relativeTime.key, { n: relativeTime.n }) }),
745
+ hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("duration.label", { name: hover.durationLabel }) }),
746
+ hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
747
+ hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ttft.label", { name: hover.ttftLabel }) }),
748
+ hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
749
+ ]
750
+ }),
751
+ (hover.modelLabel !== null || hover.purposeLabel !== null || hover.tokensLabel !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
752
+ style: {
753
+ display: "flex",
754
+ flexWrap: "wrap",
755
+ gap: 8,
756
+ color: "#8b96ab",
757
+ fontSize: 11,
758
+ marginTop: 4
759
+ },
760
+ children: [
761
+ hover.modelLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
762
+ "data-model": hover.modelLabel,
763
+ children: hover.modelLabel
764
+ }),
765
+ hover.purposeLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
766
+ "data-purpose": hover.purposeLabel,
767
+ children: hover.purposeLabel
768
+ }),
769
+ hover.tokensLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
770
+ "data-tokens": hover.tokensLabel,
771
+ children: hover.tokensLabel
772
+ })
773
+ ]
774
+ })
775
+ ]
776
+ });
777
+ }
778
+ //#endregion
236
779
  //#region src/client/useCurrentAnchor.ts
237
780
  /**
238
781
  * useCurrentAnchor: tracks which user-message row sits at/just above the
@@ -332,6 +875,21 @@ window.__ModuleLoader__.load({
332
875
  /** Minimum user messages before the rail adds value. */
333
876
  const MIN_MARKS = 2;
334
877
  const PREVIEW_LENGTH = 80;
878
+ /** Stable no-bookmarks fallback for render paths without the store seat. */
879
+ const NO_BOOKMARKS = [];
880
+ /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
881
+ const NO_KINDS = [];
882
+ /**
883
+ * Self-contained pulse keyframes for the transient badges (running/awaiting):
884
+ * an expanding currentColor ring on box-shadow plus an opacity beat, driven by
885
+ * `animation` on the badge ring span (kept in an inline <style> so the plugin
886
+ * stays zero-asset).
887
+ */
888
+ const BADGE_PULSE_CSS = `@keyframes milestone-badge-pulse {
889
+ 0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.85 }
890
+ 70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
891
+ 100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
892
+ }`;
335
893
  /** Visual dot diameter (px). */
336
894
  const DOT_SIZE = 12;
337
895
  /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
@@ -352,32 +910,12 @@ window.__ModuleLoader__.load({
352
910
  function extractPreview(content) {
353
911
  return extractText(content).slice(0, PREVIEW_LENGTH);
354
912
  }
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
913
  /** Compact duration label (ms). */
364
914
  function formatDuration(ms) {
365
915
  if (ms < 1e3) return `${ms}ms`;
366
916
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
367
917
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
368
918
  }
369
- /** Human label for a TurnEndReason kind. */
370
- function reasonLabelOf(kind) {
371
- switch (kind) {
372
- case "completed": return "已完成";
373
- case "aborted": return "已中止";
374
- case "error": return "出错";
375
- case "max-tokens": return "达到上限";
376
- case "interrupted": return "已中断";
377
- case "blocked": return "已阻塞";
378
- default: return kind;
379
- }
380
- }
381
919
  /** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
382
920
  function turnTailOf(turn) {
383
921
  const data = turn.data;
@@ -385,14 +923,22 @@ window.__ModuleLoader__.load({
385
923
  return data.get("turn-tail");
386
924
  }
387
925
  /**
388
- * @param props - session standard kit (useSession, sessionId, useProjection).
926
+ * @param props - session standard kit (useSession, sessionId, useProjection),
927
+ * the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
928
+ * actions, injected by the framework from the declared store seat), and the
929
+ * framework-synthesized `t` locale interpreter (registered via the entry's
930
+ * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
931
+ * outside the slot machinery).
389
932
  */
390
- function MilestoneRail({ useSession, loadOlder }) {
933
+ function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, t = (key) => key }) {
391
934
  const order = useSession((s) => s.chat.order);
392
935
  const nodes = useSession((s) => s.chat.nodes);
936
+ const locations = useSession((s) => s.chat.locations);
393
937
  const timeline = useSession((s) => s.chat.timeline);
938
+ const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
394
939
  const hasMore = useSession((s) => s.hasMore);
395
940
  const loadingOlder = useSession((s) => s.loadingOlder);
941
+ const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
396
942
  const marks = (0, react.useMemo)(() => {
397
943
  const result = [];
398
944
  for (const key of order) {
@@ -411,6 +957,22 @@ window.__ModuleLoader__.load({
411
957
  }
412
958
  return result;
413
959
  }, [order, nodes]);
960
+ const kindsByTurn = (0, react.useMemo)(() => {
961
+ const result = /* @__PURE__ */ new Map();
962
+ for (const node of nodes.values()) {
963
+ if (node.kind !== "turn-error" && node.kind !== "turn-max-tokens" && node.kind !== "model-retry") continue;
964
+ if (node.kind === "model-retry") {
965
+ if (node.data?.retryState === "cancelled") continue;
966
+ }
967
+ if (node.location.kind !== "turn" && node.location.kind !== "step") continue;
968
+ const kinds = result.get(node.location.turn.turn) ?? [];
969
+ kinds.push(node.kind);
970
+ result.set(node.location.turn.turn, kinds);
971
+ }
972
+ return result;
973
+ }, [order, nodes]);
974
+ const running = useSession((s) => s.running);
975
+ const awaitingInput = useSession((s) => s.pending).length > 0;
414
976
  const [railBox, setRailBox] = (0, react.useState)(null);
415
977
  const [hover, setHover] = (0, react.useState)(null);
416
978
  const [search, setSearch] = (0, react.useState)({
@@ -418,10 +980,40 @@ window.__ModuleLoader__.load({
418
980
  activePos: 0,
419
981
  panelOpen: false
420
982
  });
983
+ const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
984
+ const [copiedKey, setCopiedKey] = (0, react.useState)(null);
985
+ const [forkedKey, setForkedKey] = (0, react.useState)(null);
986
+ const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
987
+ const [focusIndex, setFocusIndex] = (0, react.useState)(0);
988
+ const listRef = (0, react.useRef)(null);
421
989
  const currentKey = useCurrentAnchor(order);
422
- const { matches } = (0, react.useMemo)(() => filterMarks(marks, search.query), [marks, search.query]);
990
+ const displayMarks = (0, react.useMemo)(() => {
991
+ if (!bookmarksOnly) return marks;
992
+ return filterByBookmarks(marks, bookmarkedKeys).visible.map((i) => marks[i]);
993
+ }, [
994
+ bookmarksOnly,
995
+ marks,
996
+ bookmarkedKeys
997
+ ]);
998
+ const { matches } = (0, react.useMemo)(() => filterMarks(displayMarks, search.query), [displayMarks, search.query]);
423
999
  const hasQuery = search.query.trim() !== "";
424
1000
  const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
1001
+ const groups = (0, react.useMemo)(() => buildTurnGroups(displayMarks), [displayMarks]);
1002
+ const render = (0, react.useMemo)(() => buildRenderList(groups, collapsedTurns), [groups, collapsedTurns]);
1003
+ const separatorIndices = (0, react.useMemo)(() => new Set(render.separatorsAt), [render]);
1004
+ const collapsedSummaries = (0, react.useMemo)(() => {
1005
+ const summaries = /* @__PURE__ */ new Map();
1006
+ for (const group of groups) if (group.turn !== null && group.marks.length > 1 && collapsedTurns.has(group.turn)) summaries.set(group.marks[group.marks.length - 1].key, group.marks.length);
1007
+ return summaries;
1008
+ }, [groups, collapsedTurns]);
1009
+ const turnMarkCounts = (0, react.useMemo)(() => {
1010
+ const counts = /* @__PURE__ */ new Map();
1011
+ for (const mark of displayMarks) {
1012
+ if (mark.turn === void 0) continue;
1013
+ counts.set(mark.turn, (counts.get(mark.turn) ?? 0) + 1);
1014
+ }
1015
+ return counts;
1016
+ }, [displayMarks]);
425
1017
  (0, react.useLayoutEffect)(() => {
426
1018
  if (marks.length < MIN_MARKS) {
427
1019
  setRailBox(null);
@@ -446,6 +1038,9 @@ window.__ModuleLoader__.load({
446
1038
  window.removeEventListener("resize", compute);
447
1039
  };
448
1040
  }, [marks.length]);
1041
+ (0, react.useLayoutEffect)(() => {
1042
+ setFocusIndex((f) => clampIndex(f, render.items.length));
1043
+ }, [render.items.length]);
449
1044
  if (railBox === null || marks.length < MIN_MARKS) return null;
450
1045
  const jump = (key) => {
451
1046
  findRow(key)?.scrollIntoView({
@@ -482,13 +1077,54 @@ window.__ModuleLoader__.load({
482
1077
  ...s,
483
1078
  activePos: next
484
1079
  }));
485
- jump(marks[matches[next]].key);
1080
+ jump(displayMarks[matches[next]].key);
486
1081
  };
487
1082
  const onSearchKeyDown = (e) => {
488
1083
  if (e.key === "Enter") advanceMatch();
489
1084
  if (e.key === "Escape") closeSearch();
490
1085
  };
1086
+ /** Focus the dot at `index` (no-op while the list is unmounted). */
1087
+ const focusDotAt = (index) => {
1088
+ listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
1089
+ };
1090
+ /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
1091
+ const onListFocus = (e) => {
1092
+ if (e.target !== e.currentTarget) return;
1093
+ focusDotAt(clampIndex(focusIndex, render.items.length));
1094
+ };
1095
+ /**
1096
+ * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
1097
+ * jump to first/last. Enter/Space are deliberately NOT handled — the dots
1098
+ * are real buttons, so native activation fires the jump click untouched
1099
+ * (preventDefault here would swallow it). The rover counts RENDERED dots
1100
+ * (collapsed turns shrink the list).
1101
+ */
1102
+ const onListKeyDown = (e) => {
1103
+ const count = render.items.length;
1104
+ let next = null;
1105
+ switch (e.key) {
1106
+ case "ArrowDown":
1107
+ next = nextFocusIndex(focusIndex, count, 1);
1108
+ break;
1109
+ case "ArrowUp":
1110
+ next = nextFocusIndex(focusIndex, count, -1);
1111
+ break;
1112
+ case "Home":
1113
+ next = 0;
1114
+ break;
1115
+ case "End":
1116
+ next = count - 1;
1117
+ break;
1118
+ default: return;
1119
+ }
1120
+ e.preventDefault();
1121
+ const target = clampIndex(next, count);
1122
+ setFocusIndex(target);
1123
+ focusDotAt(target);
1124
+ };
491
1125
  const buildHover = (mark, index) => {
1126
+ if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
1127
+ if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
492
1128
  const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
493
1129
  let durationLabel = null;
494
1130
  let reasonLabel = null;
@@ -498,7 +1134,7 @@ window.__ModuleLoader__.load({
498
1134
  if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
499
1135
  if (turn.end !== void 0) {
500
1136
  const reason = turn.end.data.reason;
501
- if (reason?.kind !== void 0) reasonLabel = reasonLabelOf(reason.kind);
1137
+ if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
502
1138
  }
503
1139
  const tail = turnTailOf(turn);
504
1140
  if (tail !== void 0) {
@@ -506,17 +1142,61 @@ window.__ModuleLoader__.load({
506
1142
  if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
507
1143
  }
508
1144
  }
1145
+ const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
509
1146
  return {
510
1147
  mark,
511
1148
  index,
512
- total: marks.length,
513
- turnLabel: mark.turn !== void 0 ? `第 ${mark.turn} 轮` : null,
1149
+ total: displayMarks.length,
1150
+ turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
514
1151
  durationLabel,
515
1152
  reasonLabel,
516
1153
  ttftLabel,
517
- tpsLabel
1154
+ tpsLabel,
1155
+ modelLabel: meta.model,
1156
+ purposeLabel: meta.purpose,
1157
+ tokensLabel: meta.inputTokens !== null && meta.outputTokens !== null ? `${meta.inputTokens} / ${meta.outputTokens} tok` : null,
1158
+ turnMarkCount: mark.turn !== void 0 ? turnMarkCounts.get(mark.turn) ?? 0 : null
518
1159
  };
519
1160
  };
1161
+ /**
1162
+ * C4: collapse/expand the hovered mark's turn in the rail. The set is
1163
+ * replaced immutably (a turn toggles out when already present); collapsing
1164
+ * keeps the turn's LAST mark visible via buildRenderList.
1165
+ */
1166
+ const onToggleCollapse = (turn) => {
1167
+ setCollapsedTurns((prev) => {
1168
+ const next = new Set(prev);
1169
+ if (next.has(turn)) next.delete(turn);
1170
+ else next.add(turn);
1171
+ return next;
1172
+ });
1173
+ };
1174
+ /**
1175
+ * T10: flip a mark's bookmark in the persisted store. The store action is
1176
+ * the write path (the engine persists synchronously). The hover re-assert
1177
+ * forces a re-render so the star reflects the toggled state — production
1178
+ * re-renders through the framework's uSES-bound useStore; the component
1179
+ * test harness injects an unsubscribed selector, so this local re-render is
1180
+ * what syncs the DOM there. Both paths converge on the same fresh snapshot.
1181
+ */
1182
+ const onToggleBookmark = (key) => {
1183
+ actions?.toggle(key);
1184
+ setHover((h) => h === null ? h : { ...h });
1185
+ };
1186
+ /**
1187
+ * C3: copy the hovered mark's FULL message text to the system clipboard.
1188
+ * The acknowledgement only shows when the write actually succeeded.
1189
+ */
1190
+ const onCopy = async (mark) => {
1191
+ if (await copyText(mark.text)) setCopiedKey(mark.key);
1192
+ };
1193
+ /**
1194
+ * C3: fork the session at the hovered mark, anchoring the cut at its event
1195
+ * seq. The acknowledgement only shows once the fork resolved.
1196
+ */
1197
+ const onFork = (mark) => {
1198
+ forkAt(mark.seq).then(() => setForkedKey(mark.key));
1199
+ };
520
1200
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
521
1201
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
522
1202
  style: {
@@ -530,14 +1210,15 @@ window.__ModuleLoader__.load({
530
1210
  display: "flex",
531
1211
  flexDirection: "column"
532
1212
  },
533
- "aria-label": "会话里程碑",
1213
+ "aria-label": t("rail.label"),
534
1214
  children: [
1215
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
535
1216
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
536
1217
  type: "button",
537
1218
  "data-load-older": true,
538
1219
  "data-loading-older": loadingOlder ? "true" : void 0,
539
- title: "加载更早消息",
540
- "aria-label": "加载更早消息",
1220
+ title: t("load.older"),
1221
+ "aria-label": t("load.older"),
541
1222
  disabled: loadingOlder,
542
1223
  onClick: () => {
543
1224
  loadOlder();
@@ -560,22 +1241,61 @@ window.__ModuleLoader__.load({
560
1241
  },
561
1242
  children: "···"
562
1243
  }),
1244
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1245
+ type: "button",
1246
+ "data-bookmarks-toggle": true,
1247
+ "aria-label": t("bookmark.filter"),
1248
+ "aria-pressed": bookmarksOnly,
1249
+ "data-active": bookmarksOnly ? "true" : void 0,
1250
+ onClick: () => setBookmarksOnly((v) => !v),
1251
+ style: {
1252
+ width: DOT_HIT,
1253
+ height: DOT_HIT,
1254
+ flexShrink: 0,
1255
+ display: "flex",
1256
+ alignItems: "center",
1257
+ justifyContent: "center",
1258
+ background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
1259
+ border: "none",
1260
+ padding: 0,
1261
+ cursor: "pointer",
1262
+ color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
1263
+ },
1264
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1265
+ width: "13",
1266
+ height: "13",
1267
+ viewBox: "0 0 24 24",
1268
+ fill: bookmarksOnly ? "currentColor" : "none",
1269
+ stroke: "currentColor",
1270
+ strokeWidth: "2",
1271
+ strokeLinejoin: "round",
1272
+ "aria-hidden": "true",
1273
+ 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" })
1274
+ })
1275
+ }),
563
1276
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
564
1277
  panelTop: railBox.top,
565
1278
  panelRight: railBox.right + DOT_HIT + 8,
566
1279
  query: search.query,
567
1280
  panelOpen: search.panelOpen,
568
1281
  matches: matches.length,
569
- total: marks.length,
1282
+ total: displayMarks.length,
570
1283
  onToggle: () => setSearch((s) => ({
571
1284
  ...s,
572
1285
  panelOpen: !s.panelOpen
573
1286
  })),
574
1287
  onQueryChange: updateQuery,
575
1288
  onSearchKeyDown,
576
- onClear: clearSearch
1289
+ onClear: clearSearch,
1290
+ t
577
1291
  }),
578
1292
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1293
+ ref: listRef,
1294
+ "data-rail-list": true,
1295
+ tabIndex: 0,
1296
+ "aria-label": t("rail.list"),
1297
+ onFocus: onListFocus,
1298
+ onKeyDown: onListKeyDown,
579
1299
  style: {
580
1300
  flex: 1,
581
1301
  minHeight: 0,
@@ -587,17 +1307,38 @@ window.__ModuleLoader__.load({
587
1307
  padding: "6px 0",
588
1308
  scrollbarWidth: "none"
589
1309
  },
590
- children: marks.map((mark, i) => {
1310
+ children: render.items.map((item, i) => {
1311
+ const showSeparator = separatorIndices.has(i);
1312
+ const mark = displayMarks[item.displayIndex];
1313
+ const summaryCount = collapsedSummaries.get(mark.key);
1314
+ const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
591
1315
  const dotState = markState({
592
1316
  key: mark.key,
593
1317
  hasQuery,
594
- isMatch: matches.includes(i),
595
- isActive: i === activeMarkIndex,
1318
+ isMatch: matches.includes(item.displayIndex),
1319
+ isActive: item.displayIndex === activeMarkIndex,
596
1320
  isCurrent: !hasQuery && mark.key === currentKey
597
1321
  });
598
1322
  const isHovered = hover?.mark.key === mark.key;
599
1323
  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", {
1324
+ const badge = deriveBadge({
1325
+ nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
1326
+ lastMark: item.displayIndex === displayMarks.length - 1,
1327
+ running,
1328
+ awaitingInput
1329
+ });
1330
+ const ringStyle = badge === null ? null : badgeRingStyle(badge);
1331
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [showSeparator && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1332
+ "data-turn-separator": true,
1333
+ "data-turn": mark.turn === void 0 ? void 0 : mark.turn,
1334
+ style: {
1335
+ width: DOT_HIT - 8,
1336
+ height: 1,
1337
+ flexShrink: 0,
1338
+ background: "rgba(139, 150, 171, 0.35)",
1339
+ borderRadius: 1
1340
+ }
1341
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
601
1342
  type: "button",
602
1343
  style: {
603
1344
  width: DOT_HIT,
@@ -614,90 +1355,65 @@ window.__ModuleLoader__.load({
614
1355
  onMouseEnter: (e) => {
615
1356
  const rect = e.currentTarget.getBoundingClientRect();
616
1357
  setHover({
617
- ...buildHover(mark, i),
1358
+ ...buildHover(mark, item.displayIndex),
618
1359
  top: rect.top + rect.height / 2
619
1360
  });
620
1361
  },
621
- onMouseLeave: () => setHover(null),
622
1362
  onClick: () => jump(mark.key),
623
- "aria-label": `跳转到第 ${i + 1} 条消息`,
1363
+ "data-rail-dot": true,
1364
+ "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
1365
+ "data-collapsed-count": summaryCount,
1366
+ tabIndex: focusIndex === i ? 0 : -1,
1367
+ onFocus: () => setFocusIndex(i),
1368
+ "aria-label": t("jump.to", { n: item.displayIndex + 1 }),
624
1369
  "aria-current": dotState === "active" ? "true" : void 0,
625
1370
  "data-current": dotState === "current" ? "true" : void 0,
626
1371
  "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);
1372
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1373
+ style: {
1374
+ position: "relative",
1375
+ width: DOT_SIZE,
1376
+ height: DOT_SIZE,
1377
+ borderRadius: "50%",
1378
+ background: dotColor(item.displayIndex, marks.length),
1379
+ boxShadow,
1380
+ transition: "transform 120ms ease, opacity 120ms ease",
1381
+ transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
1382
+ opacity: isHovered || dotState !== "dimmed" ? 1 : .22
1383
+ },
1384
+ "data-bookmarked": bookmarked ? "true" : void 0,
1385
+ children: ringStyle !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1386
+ "data-badge": badge,
1387
+ style: {
1388
+ position: "absolute",
1389
+ inset: -3,
1390
+ borderRadius: "50%",
1391
+ border: `2px solid ${ringStyle.color}`,
1392
+ color: ringStyle.color,
1393
+ pointerEvents: "none",
1394
+ animation: ringStyle.pulse ? "milestone-badge-pulse 1.4s ease-out infinite" : void 0
1395
+ }
1396
+ })
1397
+ })
1398
+ })] }, mark.key);
638
1399
  })
639
1400
  }),
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
- ]
1401
+ hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
1402
+ panelRight: railBox.right + DOT_HIT + 8,
1403
+ hover,
1404
+ bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
1405
+ onToggleBookmark: () => onToggleBookmark(hover.mark.key),
1406
+ onCopy,
1407
+ onFork,
1408
+ copied: copiedKey === hover.mark.key,
1409
+ forked: forkedKey === hover.mark.key,
1410
+ turnCollapsed: hover.mark.turn !== void 0 && collapsedTurns.has(hover.mark.turn),
1411
+ onToggleCollapse,
1412
+ onMouseEnter: () => setHover((h) => h),
1413
+ onMouseLeave: () => setHover(null),
1414
+ t
699
1415
  }),
700
- showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1416
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
701
1417
  "data-window-hint": true,
702
1418
  style: {
703
1419
  position: "absolute",
@@ -711,16 +1427,42 @@ window.__ModuleLoader__.load({
711
1427
  pointerEvents: "none",
712
1428
  userSelect: "none"
713
1429
  },
714
- children: [
715
- "已显示 ",
716
- marks.length,
717
- " 条 · 还有更早"
718
- ]
1430
+ children: t("window.hint", { n: marks.length })
719
1431
  })
720
1432
  ]
721
1433
  });
722
1434
  }
723
1435
  //#endregion
1436
+ //#region src/client/bookmarkStore.ts
1437
+ /**
1438
+ * Persisted per-session bookmarks store for the milestone rail.
1439
+ *
1440
+ * A thin declarative shell over the harness snapshot-store engine: pure
1441
+ * draft-mutator actions, persisted to localStorage under the key
1442
+ * `dsh-milestone.bookmarks` (+ `.${scopeKey}` for session-scope instances,
1443
+ * resolved by the engine's `create(scopeKey)`). Consumers must call the
1444
+ * FACTORY (never a module-level handle — module-cache identity is a disguised
1445
+ * singleton across plugin reloads).
1446
+ */
1447
+ /**
1448
+ * Declare the bookmarks store handle. Returns a fresh handle per call; the
1449
+ * framework (or tests) create per-session instances via `create(scopeKey)`.
1450
+ */
1451
+ function createBookmarksStore() {
1452
+ return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
1453
+ init: () => ({ keys: [] }),
1454
+ persist: "dsh-milestone.bookmarks",
1455
+ actions: {
1456
+ toggle: (draft, key) => {
1457
+ draft.keys = toggleKey(draft.keys, key);
1458
+ },
1459
+ clear: (draft) => {
1460
+ draft.keys = [];
1461
+ }
1462
+ }
1463
+ });
1464
+ }
1465
+ //#endregion
724
1466
  //#region src/client/railInject.ts
725
1467
  /**
726
1468
  * Wrap a session-bound `loadOlder` call into a safe action closure.
@@ -740,10 +1482,138 @@ window.__ModuleLoader__.load({
740
1482
  await binding.session.loadOlder();
741
1483
  };
742
1484
  }
1485
+ /**
1486
+ * Wrap a session `fork` call into a safe action closure that anchors the cut
1487
+ * at an event seq and always bumps the inherited title.
1488
+ *
1489
+ * - Delegates to `sessions.fork({ sessionId, atSeq, increaseTitle: true })`;
1490
+ * the resolved child id is passed through.
1491
+ * - A rejection propagates unchanged so callers can surface the fork error.
1492
+ *
1493
+ * @param sessions - the injected sessions service (`ctx.sessions`).
1494
+ * @param sessionId - the session the rail is scoped to.
1495
+ * @returns an action that forks that session at a given event seq.
1496
+ */
1497
+ function createForkAt(sessions, sessionId) {
1498
+ return (atSeq) => sessions.fork({
1499
+ sessionId,
1500
+ atSeq,
1501
+ increaseTitle: true
1502
+ });
1503
+ }
1504
+ //#endregion
1505
+ //#region src/client/locales.ts
1506
+ /**
1507
+ * UI strings for the milestone rail, keyed flat (single-language-per-key,
1508
+ * no nesting) so the later i18n threading stays a mechanical
1509
+ * `value.replace('{name}', n)` substitution.
1510
+ *
1511
+ * `zh` is the source of truth and the key registry: it byte-matches the
1512
+ * current hardcoded output of MilestoneRail / MilestoneRailTooltip /
1513
+ * MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
1514
+ * the interpolated number or label), so swapping in these templates is
1515
+ * behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
1516
+ * missing English translation is a compile error, not a runtime miss.
1517
+ */
1518
+ const zh = {
1519
+ /** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
1520
+ "jump.to": "跳转到第 {n} 条消息",
1521
+ /** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
1522
+ "window.hint": "已显示 {n} 条 · 还有更早",
1523
+ /** Hover turn badge: `第 ${mark.turn} 轮`. */
1524
+ "turn.label": "第 {n} 轮",
1525
+ /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
1526
+ "pos.of": "第 {n} / {m} 条",
1527
+ /** Search input placeholder. */
1528
+ "search.placeholder": "搜索消息内容",
1529
+ /** aria-label on the search toggle button and the search input. */
1530
+ "search.label": "搜索消息",
1531
+ /** aria-label on the bookmarks-only filter toggle. */
1532
+ "bookmark.filter": "只看收藏",
1533
+ /** aria-label on the hover tooltip star toggle. */
1534
+ "bookmark.star": "收藏此消息",
1535
+ /** aria-label on the search clear button. */
1536
+ "search.clear": "清空搜索",
1537
+ /** title + aria-label on the load-older `···` button. */
1538
+ "load.older": "加载更早消息",
1539
+ /** aria-label on the rail root. */
1540
+ "rail.label": "会话里程碑",
1541
+ /** aria-label on the dot list. */
1542
+ "rail.list": "会话里程碑列表",
1543
+ /** Hover preview fallback for empty message text. */
1544
+ "no.text": "(无文本)",
1545
+ /** Relative time: `< 60s`. */
1546
+ "time.justNow": "刚刚",
1547
+ /** Relative time: `< 1h`. */
1548
+ "time.minutes": "{n} 分钟前",
1549
+ /** Relative time: `< 1d`. */
1550
+ "time.hours": "{n} 小时前",
1551
+ /** Relative time: `>= 1d`. */
1552
+ "time.days": "{n} 天前",
1553
+ /** Hover duration: `用时 {durationLabel}`. */
1554
+ "duration.label": "用时 {name}",
1555
+ /** Hover TTFT: `首字 {ttftLabel}`. */
1556
+ "ttft.label": "首字 {name}",
1557
+ /** TurnEndReason `completed`. */
1558
+ "reason.completed": "已完成",
1559
+ /** TurnEndReason `aborted`. */
1560
+ "reason.aborted": "已中止",
1561
+ /** TurnEndReason `error`. */
1562
+ "reason.error": "出错",
1563
+ /** TurnEndReason `max-tokens`. */
1564
+ "reason.maxTokens": "达到上限",
1565
+ /** TurnEndReason `interrupted`. */
1566
+ "reason.interrupted": "已中断",
1567
+ /** TurnEndReason `blocked`. */
1568
+ "reason.blocked": "已阻塞",
1569
+ /** Copy-message tooltip action. */
1570
+ "copy.message": "复制消息",
1571
+ /** Fork-from-here tooltip action. */
1572
+ "fork.here": "从此处 fork",
1573
+ /** Collapse-turn tooltip action. */
1574
+ "collapse.turn": "折叠此轮",
1575
+ /** Expand-turn tooltip action. */
1576
+ "expand.turn": "展开此轮"
1577
+ };
1578
+ const en = {
1579
+ "jump.to": "Jump to message {n}",
1580
+ "window.hint": "Showing {n} messages · more below",
1581
+ "turn.label": "Turn {n}",
1582
+ "pos.of": "Message {n} of {m}",
1583
+ "search.placeholder": "Search message content",
1584
+ "search.label": "Search messages",
1585
+ "bookmark.filter": "Bookmarks only",
1586
+ "bookmark.star": "Bookmark this message",
1587
+ "search.clear": "Clear search",
1588
+ "load.older": "Load older messages",
1589
+ "rail.label": "Session milestones",
1590
+ "rail.list": "Session milestone list",
1591
+ "no.text": "(no text)",
1592
+ "time.justNow": "Just now",
1593
+ "time.minutes": "{n} minutes ago",
1594
+ "time.hours": "{n} hours ago",
1595
+ "time.days": "{n} days ago",
1596
+ "duration.label": "Duration {name}",
1597
+ "ttft.label": "First token {name}",
1598
+ "reason.completed": "Completed",
1599
+ "reason.aborted": "Aborted",
1600
+ "reason.error": "Error",
1601
+ "reason.maxTokens": "Max tokens reached",
1602
+ "reason.interrupted": "Interrupted",
1603
+ "reason.blocked": "Blocked",
1604
+ "copy.message": "Copy message",
1605
+ "fork.here": "Fork from here",
1606
+ "collapse.turn": "Collapse turn",
1607
+ "expand.turn": "Expand turn"
1608
+ };
743
1609
  //#endregion
744
1610
  //#region src/client/index.ts
745
1611
  /** Required services (cordis fiber inject). */
746
- const inject = ["slots", "sessions"];
1612
+ const inject = [
1613
+ "slots",
1614
+ "sessions",
1615
+ "locale"
1616
+ ];
747
1617
  /**
748
1618
  * Register the overlay and rail once their slot declarations are on the
749
1619
  * ledger. The overlay registers directly against the shipped shell.overlay
@@ -752,6 +1622,10 @@ window.__ModuleLoader__.load({
752
1622
  * @param ctx - client root context.
753
1623
  */
754
1624
  function apply(ctx) {
1625
+ ctx.effect(() => ctx.locale.register("dsh-milestone", {
1626
+ zh,
1627
+ en
1628
+ }), "dsh-milestone: dictionaries");
755
1629
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
756
1630
  name: "shell.overlay",
757
1631
  id: "milestone",
@@ -763,7 +1637,12 @@ window.__ModuleLoader__.load({
763
1637
  }, MilestoneOverlay));
764
1638
  ctx.slots.inject("milestone.rail", () => ctx.slots.register({
765
1639
  name: "milestone.rail",
766
- inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
1640
+ store: createBookmarksStore,
1641
+ locale: "dsh-milestone",
1642
+ inject: (sessionId) => ({
1643
+ loadOlder: createLoadOlder(ctx.sessions, sessionId),
1644
+ forkAt: createForkAt(ctx.sessions, sessionId)
1645
+ })
767
1646
  }, MilestoneRail));
768
1647
  }
769
1648
  //#endregion