dsh-milestone 0.4.0 → 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 +8 -1
  2. package/lib/client.js +567 -73
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -40,15 +40,20 @@
40
40
  - **蓝色渐变** —— 最新最深、最早最浅,一眼看清提问的先后顺序,像 Git 提交图。
41
41
  - **滚轮滑动** —— 长会话圆点超出可视区时,鼠标在里程碑条上滚轮即可滑动选点。
42
42
  - **丰富悬停** —— 悬停展示消息预览、相对时间、第 N 轮、用时、结束原因、首字延迟(TTFT)、tokens/秒。
43
+ - **模型与用量** —— 悬停直接看这一轮用的哪个模型、输入/输出 token 数,调试成本一目了然。
44
+ - **turn 分组折叠** —— 圆点按轮次分组,一眼看清对话章节;长轮次可折叠成一条,减少干扰。
45
+ - **复制与 fork** —— 悬停一键复制该条提问全文,或「从此处 fork」开一个分支会话。
46
+ - **中英双语** —— 跟随 harness 界面语言自动切换中英文。
43
47
  - **零侵入** —— 官方 slot 机制挂载,不修改 harness 源码,装完即用。
44
48
 
45
49
  ## 悬停能看到什么
46
50
 
47
51
  ```
48
52
  ┌─────────────────────────────────────────┐
49
- │ 第 3 / 5 条 · 第 2 轮 │ ← 序号 + 轮次
53
+ │ 第 3 / 5 条 · 第 2 轮 ☆ 复制 ✂ │ ← 序号 + 轮次 + 收藏/复制/fork 动作
50
54
  │ 帮我优化这段代码的性能 │ ← 消息预览(前 80 字)
51
55
  │ 5 分钟前 · 用时 1m30s · 首字 1.2s · 12.4 tok/s │ ← 时间 · 耗时 · TTFT · 吞吐
56
+ │ v4 · continue · 1280 / 2560 tok │ ← 模型 · 用途 · token 用量
52
57
  └─────────────────────────────────────────┘
53
58
  ```
54
59
 
@@ -93,6 +98,8 @@ shell.overlay (root scope)
93
98
  - TTFT / tokens/秒 依赖 turn 位置数据,窗口外或未完成的 turn 不显示(自动隐藏)。
94
99
  - 徽章的瞬态状态(运行中/等待输入)只点亮**最新一条可见提问**——若运行中/等待输入的轮次其提问在窗口外,则无脉冲。
95
100
  - 书签按**会话**隔离(不跨会话共享)。
101
+ - 模型 / token 用量依赖该轮 assistant 节点的元数据,部分场景下缺失则自动隐藏该行。
102
+ - fork 从选中消息所在轮次开始分支,不会自动打开子会话(需在会话列表手动打开)。
96
103
  - 尚无全局快捷键聚焦里程碑条(需 Tab 键切换到)。
97
104
 
98
105
  ## License
package/lib/client.js CHANGED
@@ -126,6 +126,148 @@ window.__ModuleLoader__.load({
126
126
  };
127
127
  }
128
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
129
271
  //#region src/client/rail-keyboard.ts
130
272
  /**
131
273
  * Pure roving-tabindex index math for the milestone rail.
@@ -242,17 +384,83 @@ window.__ModuleLoader__.load({
242
384
  return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
243
385
  }
244
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
245
453
  //#region src/client/MilestoneRailSearch.tsx
246
454
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
247
455
  const DOT_HIT$1 = 22;
248
456
  /**
249
457
  * @param props - the search state slice plus the rail's event handlers.
250
458
  */
251
- 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 }) {
252
460
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
253
461
  type: "button",
254
462
  "data-search-toggle": true,
255
- "aria-label": "搜索消息",
463
+ "aria-label": t("search.label"),
256
464
  "aria-pressed": panelOpen,
257
465
  onClick: onToggle,
258
466
  style: {
@@ -304,8 +512,8 @@ window.__ModuleLoader__.load({
304
512
  },
305
513
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
306
514
  "data-rail-search": true,
307
- "aria-label": "搜索消息",
308
- placeholder: "搜索消息内容",
515
+ "aria-label": t("search.label"),
516
+ placeholder: t("search.placeholder"),
309
517
  value: query,
310
518
  onChange: (e) => onQueryChange(e.target.value),
311
519
  onKeyDown: onSearchKeyDown,
@@ -324,7 +532,7 @@ window.__ModuleLoader__.load({
324
532
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
325
533
  type: "button",
326
534
  "data-search-clear": true,
327
- "aria-label": "清空搜索",
535
+ "aria-label": t("search.clear"),
328
536
  onClick: onClear,
329
537
  style: {
330
538
  width: 22,
@@ -371,7 +579,10 @@ window.__ModuleLoader__.load({
371
579
  /**
372
580
  * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
373
581
  */
374
- function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onMouseEnter, onMouseLeave, panelRight }) {
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;
375
586
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
376
587
  onMouseEnter,
377
588
  onMouseLeave,
@@ -399,24 +610,22 @@ window.__ModuleLoader__.load({
399
610
  style: {
400
611
  display: "flex",
401
612
  alignItems: "center",
613
+ flexWrap: "wrap",
402
614
  gap: 8,
403
615
  color: "#9aa4b8",
404
616
  fontSize: 11,
405
617
  marginBottom: 4
406
618
  },
407
619
  children: [
408
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
409
- "第 ",
410
- hover.index + 1,
411
- " / ",
412
- hover.total,
413
- " 条"
414
- ] }),
620
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pos.of", {
621
+ n: hover.index + 1,
622
+ m: hover.total
623
+ }) }),
415
624
  hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
416
625
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
417
626
  type: "button",
418
627
  "data-star": true,
419
- "aria-label": "收藏此消息",
628
+ "aria-label": t("bookmark.star"),
420
629
  "aria-pressed": bookmarked,
421
630
  "data-starred": bookmarked ? "true" : void 0,
422
631
  onClick: (e) => {
@@ -448,12 +657,79 @@ window.__ModuleLoader__.load({
448
657
  "aria-hidden": "true",
449
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" })
450
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")
451
727
  })
452
728
  ]
453
729
  }),
454
730
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
455
731
  style: { color: "#c7cede" },
456
- children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
732
+ children: hover.mark.preview !== "" ? hover.mark.preview : t("no.text")
457
733
  }),
458
734
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
459
735
  style: {
@@ -465,24 +741,40 @@ window.__ModuleLoader__.load({
465
741
  marginTop: 4
466
742
  },
467
743
  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] }),
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 }) }),
470
746
  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] }),
747
+ hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ttft.label", { name: hover.ttftLabel }) }),
472
748
  hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
473
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
+ ]
474
774
  })
475
775
  ]
476
776
  });
477
777
  }
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
778
  //#endregion
487
779
  //#region src/client/useCurrentAnchor.ts
488
780
  /**
@@ -624,18 +916,6 @@ window.__ModuleLoader__.load({
624
916
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
625
917
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
626
918
  }
627
- /** Human label for a TurnEndReason kind. */
628
- function reasonLabelOf(kind) {
629
- switch (kind) {
630
- case "completed": return "已完成";
631
- case "aborted": return "已中止";
632
- case "error": return "出错";
633
- case "max-tokens": return "达到上限";
634
- case "interrupted": return "已中断";
635
- case "blocked": return "已阻塞";
636
- default: return kind;
637
- }
638
- }
639
919
  /** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
640
920
  function turnTailOf(turn) {
641
921
  const data = turn.data;
@@ -644,13 +924,18 @@ window.__ModuleLoader__.load({
644
924
  }
645
925
  /**
646
926
  * @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).
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).
649
932
  */
650
- function MilestoneRail({ useSession, loadOlder, useStore, actions }) {
933
+ function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, t = (key) => key }) {
651
934
  const order = useSession((s) => s.chat.order);
652
935
  const nodes = useSession((s) => s.chat.nodes);
936
+ const locations = useSession((s) => s.chat.locations);
653
937
  const timeline = useSession((s) => s.chat.timeline);
938
+ const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
654
939
  const hasMore = useSession((s) => s.hasMore);
655
940
  const loadingOlder = useSession((s) => s.loadingOlder);
656
941
  const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
@@ -696,6 +981,9 @@ window.__ModuleLoader__.load({
696
981
  panelOpen: false
697
982
  });
698
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());
699
987
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
700
988
  const listRef = (0, react.useRef)(null);
701
989
  const currentKey = useCurrentAnchor(order);
@@ -710,6 +998,22 @@ window.__ModuleLoader__.load({
710
998
  const { matches } = (0, react.useMemo)(() => filterMarks(displayMarks, search.query), [displayMarks, search.query]);
711
999
  const hasQuery = search.query.trim() !== "";
712
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]);
713
1017
  (0, react.useLayoutEffect)(() => {
714
1018
  if (marks.length < MIN_MARKS) {
715
1019
  setRailBox(null);
@@ -735,8 +1039,8 @@ window.__ModuleLoader__.load({
735
1039
  };
736
1040
  }, [marks.length]);
737
1041
  (0, react.useLayoutEffect)(() => {
738
- setFocusIndex((f) => clampIndex(f, displayMarks.length));
739
- }, [displayMarks.length]);
1042
+ setFocusIndex((f) => clampIndex(f, render.items.length));
1043
+ }, [render.items.length]);
740
1044
  if (railBox === null || marks.length < MIN_MARKS) return null;
741
1045
  const jump = (key) => {
742
1046
  findRow(key)?.scrollIntoView({
@@ -786,16 +1090,17 @@ window.__ModuleLoader__.load({
786
1090
  /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
787
1091
  const onListFocus = (e) => {
788
1092
  if (e.target !== e.currentTarget) return;
789
- focusDotAt(clampIndex(focusIndex, displayMarks.length));
1093
+ focusDotAt(clampIndex(focusIndex, render.items.length));
790
1094
  };
791
1095
  /**
792
1096
  * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
793
1097
  * jump to first/last. Enter/Space are deliberately NOT handled — the dots
794
1098
  * are real buttons, so native activation fires the jump click untouched
795
- * (preventDefault here would swallow it).
1099
+ * (preventDefault here would swallow it). The rover counts RENDERED dots
1100
+ * (collapsed turns shrink the list).
796
1101
  */
797
1102
  const onListKeyDown = (e) => {
798
- const count = displayMarks.length;
1103
+ const count = render.items.length;
799
1104
  let next = null;
800
1105
  switch (e.key) {
801
1106
  case "ArrowDown":
@@ -818,6 +1123,8 @@ window.__ModuleLoader__.load({
818
1123
  focusDotAt(target);
819
1124
  };
820
1125
  const buildHover = (mark, index) => {
1126
+ if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
1127
+ if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
821
1128
  const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
822
1129
  let durationLabel = null;
823
1130
  let reasonLabel = null;
@@ -827,7 +1134,7 @@ window.__ModuleLoader__.load({
827
1134
  if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
828
1135
  if (turn.end !== void 0) {
829
1136
  const reason = turn.end.data.reason;
830
- if (reason?.kind !== void 0) reasonLabel = reasonLabelOf(reason.kind);
1137
+ if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
831
1138
  }
832
1139
  const tail = turnTailOf(turn);
833
1140
  if (tail !== void 0) {
@@ -835,18 +1142,36 @@ window.__ModuleLoader__.load({
835
1142
  if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
836
1143
  }
837
1144
  }
1145
+ const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
838
1146
  return {
839
1147
  mark,
840
1148
  index,
841
1149
  total: displayMarks.length,
842
- turnLabel: mark.turn !== void 0 ? `第 ${mark.turn} 轮` : null,
1150
+ turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
843
1151
  durationLabel,
844
1152
  reasonLabel,
845
1153
  ttftLabel,
846
- 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
847
1159
  };
848
1160
  };
849
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
+ /**
850
1175
  * T10: flip a mark's bookmark in the persisted store. The store action is
851
1176
  * the write path (the engine persists synchronously). The hover re-assert
852
1177
  * forces a re-render so the star reflects the toggled state — production
@@ -858,6 +1183,20 @@ window.__ModuleLoader__.load({
858
1183
  actions?.toggle(key);
859
1184
  setHover((h) => h === null ? h : { ...h });
860
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
+ };
861
1200
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
862
1201
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
863
1202
  style: {
@@ -871,15 +1210,15 @@ window.__ModuleLoader__.load({
871
1210
  display: "flex",
872
1211
  flexDirection: "column"
873
1212
  },
874
- "aria-label": "会话里程碑",
1213
+ "aria-label": t("rail.label"),
875
1214
  children: [
876
1215
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
877
1216
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
878
1217
  type: "button",
879
1218
  "data-load-older": true,
880
1219
  "data-loading-older": loadingOlder ? "true" : void 0,
881
- title: "加载更早消息",
882
- "aria-label": "加载更早消息",
1220
+ title: t("load.older"),
1221
+ "aria-label": t("load.older"),
883
1222
  disabled: loadingOlder,
884
1223
  onClick: () => {
885
1224
  loadOlder();
@@ -905,7 +1244,7 @@ window.__ModuleLoader__.load({
905
1244
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
906
1245
  type: "button",
907
1246
  "data-bookmarks-toggle": true,
908
- "aria-label": "只看收藏",
1247
+ "aria-label": t("bookmark.filter"),
909
1248
  "aria-pressed": bookmarksOnly,
910
1249
  "data-active": bookmarksOnly ? "true" : void 0,
911
1250
  onClick: () => setBookmarksOnly((v) => !v),
@@ -947,13 +1286,14 @@ window.__ModuleLoader__.load({
947
1286
  })),
948
1287
  onQueryChange: updateQuery,
949
1288
  onSearchKeyDown,
950
- onClear: clearSearch
1289
+ onClear: clearSearch,
1290
+ t
951
1291
  }),
952
1292
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
953
1293
  ref: listRef,
954
1294
  "data-rail-list": true,
955
1295
  tabIndex: 0,
956
- "aria-label": "会话里程碑列表",
1296
+ "aria-label": t("rail.list"),
957
1297
  onFocus: onListFocus,
958
1298
  onKeyDown: onListKeyDown,
959
1299
  style: {
@@ -967,25 +1307,38 @@ window.__ModuleLoader__.load({
967
1307
  padding: "6px 0",
968
1308
  scrollbarWidth: "none"
969
1309
  },
970
- children: displayMarks.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);
971
1314
  const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
972
1315
  const dotState = markState({
973
1316
  key: mark.key,
974
1317
  hasQuery,
975
- isMatch: matches.includes(i),
976
- isActive: i === activeMarkIndex,
1318
+ isMatch: matches.includes(item.displayIndex),
1319
+ isActive: item.displayIndex === activeMarkIndex,
977
1320
  isCurrent: !hasQuery && mark.key === currentKey
978
1321
  });
979
1322
  const isHovered = hover?.mark.key === mark.key;
980
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";
981
1324
  const badge = deriveBadge({
982
1325
  nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
983
- lastMark: i === displayMarks.length - 1,
1326
+ lastMark: item.displayIndex === displayMarks.length - 1,
984
1327
  running,
985
1328
  awaitingInput
986
1329
  });
987
1330
  const ringStyle = badge === null ? null : badgeRingStyle(badge);
988
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
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", {
989
1342
  type: "button",
990
1343
  style: {
991
1344
  width: DOT_HIT,
@@ -1002,15 +1355,17 @@ window.__ModuleLoader__.load({
1002
1355
  onMouseEnter: (e) => {
1003
1356
  const rect = e.currentTarget.getBoundingClientRect();
1004
1357
  setHover({
1005
- ...buildHover(mark, i),
1358
+ ...buildHover(mark, item.displayIndex),
1006
1359
  top: rect.top + rect.height / 2
1007
1360
  });
1008
1361
  },
1009
1362
  onClick: () => jump(mark.key),
1010
1363
  "data-rail-dot": true,
1364
+ "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
1365
+ "data-collapsed-count": summaryCount,
1011
1366
  tabIndex: focusIndex === i ? 0 : -1,
1012
1367
  onFocus: () => setFocusIndex(i),
1013
- "aria-label": `跳转到第 ${i + 1} 条消息`,
1368
+ "aria-label": t("jump.to", { n: item.displayIndex + 1 }),
1014
1369
  "aria-current": dotState === "active" ? "true" : void 0,
1015
1370
  "data-current": dotState === "current" ? "true" : void 0,
1016
1371
  "data-dimmed": dotState === "dimmed" ? "true" : void 0,
@@ -1020,7 +1375,7 @@ window.__ModuleLoader__.load({
1020
1375
  width: DOT_SIZE,
1021
1376
  height: DOT_SIZE,
1022
1377
  borderRadius: "50%",
1023
- background: dotColor(i, marks.length),
1378
+ background: dotColor(item.displayIndex, marks.length),
1024
1379
  boxShadow,
1025
1380
  transition: "transform 120ms ease, opacity 120ms ease",
1026
1381
  transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
@@ -1040,7 +1395,7 @@ window.__ModuleLoader__.load({
1040
1395
  }
1041
1396
  })
1042
1397
  })
1043
- }, mark.key);
1398
+ })] }, mark.key);
1044
1399
  })
1045
1400
  }),
1046
1401
  hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
@@ -1048,10 +1403,17 @@ window.__ModuleLoader__.load({
1048
1403
  hover,
1049
1404
  bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
1050
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,
1051
1412
  onMouseEnter: () => setHover((h) => h),
1052
- onMouseLeave: () => setHover(null)
1413
+ onMouseLeave: () => setHover(null),
1414
+ t
1053
1415
  }),
1054
- showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1416
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1055
1417
  "data-window-hint": true,
1056
1418
  style: {
1057
1419
  position: "absolute",
@@ -1065,11 +1427,7 @@ window.__ModuleLoader__.load({
1065
1427
  pointerEvents: "none",
1066
1428
  userSelect: "none"
1067
1429
  },
1068
- children: [
1069
- "已显示 ",
1070
- marks.length,
1071
- " 条 · 还有更早"
1072
- ]
1430
+ children: t("window.hint", { n: marks.length })
1073
1431
  })
1074
1432
  ]
1075
1433
  });
@@ -1124,10 +1482,138 @@ window.__ModuleLoader__.load({
1124
1482
  await binding.session.loadOlder();
1125
1483
  };
1126
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
+ };
1127
1609
  //#endregion
1128
1610
  //#region src/client/index.ts
1129
1611
  /** Required services (cordis fiber inject). */
1130
- const inject = ["slots", "sessions"];
1612
+ const inject = [
1613
+ "slots",
1614
+ "sessions",
1615
+ "locale"
1616
+ ];
1131
1617
  /**
1132
1618
  * Register the overlay and rail once their slot declarations are on the
1133
1619
  * ledger. The overlay registers directly against the shipped shell.overlay
@@ -1136,6 +1622,10 @@ window.__ModuleLoader__.load({
1136
1622
  * @param ctx - client root context.
1137
1623
  */
1138
1624
  function apply(ctx) {
1625
+ ctx.effect(() => ctx.locale.register("dsh-milestone", {
1626
+ zh,
1627
+ en
1628
+ }), "dsh-milestone: dictionaries");
1139
1629
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1140
1630
  name: "shell.overlay",
1141
1631
  id: "milestone",
@@ -1148,7 +1638,11 @@ window.__ModuleLoader__.load({
1148
1638
  ctx.slots.inject("milestone.rail", () => ctx.slots.register({
1149
1639
  name: "milestone.rail",
1150
1640
  store: createBookmarksStore,
1151
- inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
1641
+ locale: "dsh-milestone",
1642
+ inject: (sessionId) => ({
1643
+ loadOlder: createLoadOlder(ctx.sessions, sessionId),
1644
+ forkAt: createForkAt(ctx.sessions, sessionId)
1645
+ })
1152
1646
  }, MilestoneRail));
1153
1647
  }
1154
1648
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-milestone",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -47,12 +47,14 @@
47
47
  ],
48
48
  "peerDependencies": {
49
49
  "@deepseek-ai/cordis": "^4.0.1",
50
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
50
51
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
51
52
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
52
53
  "react": "^18.2.0"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@deepseek-ai/cordis": "^4.0.1",
57
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
56
58
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
57
59
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
58
60
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",