dsh-milestone 0.4.0 → 0.6.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 +15 -1
  2. package/lib/client.js +1230 -102
  3. package/package.json +3 -1
package/lib/client.js CHANGED
@@ -126,6 +126,201 @@ 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/deep-link-logic.ts
153
+ /**
154
+ * Pure deep-link helpers for the milestone rail: parse the `#msg=<key>` URL
155
+ * hash and rebuild it.
156
+ *
157
+ * The conversation anchor key is treated as an OPAQUE string — it is a
158
+ * length-prefixed node key like `13:input-message<messageId>` or
159
+ * `14:assistant-step3:2` (the length prefix makes naive splitting ambiguous),
160
+ * so the parser never inspects or splits the key itself. Callers match the
161
+ * returned key against the session's marks, which is the real validity check.
162
+ *
163
+ * Percent-encoding: the URL fragment parser percent-encodes `"` `<` `>` `` ` ``
164
+ * and lone `%`, so a hash built with a RAW `<`-containing key would read back
165
+ * from `location.hash` percent-encoded (`13:user%3Cdl-2%3E`) and never match
166
+ * a mark after a refresh. `buildMessageHash` therefore percent-encodes those
167
+ * characters itself (a byte-exact URL), and `parseDeepLinkHash` decodes them
168
+ * back — the key round-trips through the URL untouched.
169
+ */
170
+ /** The URL hash fragment prefix that carries a message anchor key. */
171
+ const MSG_HASH_PREFIX = "msg=";
172
+ /**
173
+ * Characters the WHATWG URL fragment parser cannot round-trip raw (they are
174
+ * percent-encoded on parse): `"` (0x22), `<` (0x3C), `>` (0x3E), backtick
175
+ * (0x60), and `%` (0x25, so a literal `%` never reads as an escape start).
176
+ */
177
+ const FRAGMENT_UNSAFE = /["<>\u0060%]/g;
178
+ /**
179
+ * Parse a `location.hash` fragment into the message anchor key it references.
180
+ * @param hash - the raw `location.hash` value: `''` or a fragment starting
181
+ * with `#` (e.g. `#msg=13:input-messageabc`).
182
+ * @returns the anchor key (percent-escapes decoded), or null when the
183
+ * fragment is not a message deep link: empty hash, `#msg=` with an empty
184
+ * value, a `#msg` prefix without the `=`, any other hash shape, a hash
185
+ * missing the leading `#`, or a malformed percent escape.
186
+ */
187
+ function parseDeepLinkHash(hash) {
188
+ if (!hash.startsWith(`#${MSG_HASH_PREFIX}`)) return null;
189
+ const encoded = hash.slice(5);
190
+ if (encoded === "") return null;
191
+ try {
192
+ return decodeURIComponent(encoded);
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+ /**
198
+ * Build the URL hash fragment that deep-links to a message anchor key.
199
+ * @param key - the conversation node key (opaque, never parsed here).
200
+ */
201
+ function buildMessageHash(key) {
202
+ return `#${MSG_HASH_PREFIX}${key.replace(FRAGMENT_UNSAFE, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
203
+ }
204
+ //#endregion
205
+ //#region src/client/label-logic.ts
206
+ const MINUTE_MS = 6e4;
207
+ const HOUR_MS = 36e5;
208
+ const DAY_MS = 864e5;
209
+ /**
210
+ * Bucket an elapsed duration into a relative-time label.
211
+ *
212
+ * Buckets on `now - time` in milliseconds: below 60s -> justNow (n=0), below
213
+ * 3600s -> minutes, below 86400s -> hours, otherwise days. `n` is the whole
214
+ * count of the bucket unit (floor). Deterministic for a given `now`.
215
+ *
216
+ * @param time - the event timestamp in ms since epoch.
217
+ * @param now - the reference clock in ms since epoch.
218
+ * @returns the label key and bucket count.
219
+ */
220
+ function relativeTimeParts(time, now) {
221
+ const diff = now - time;
222
+ if (diff < MINUTE_MS) return {
223
+ key: "time.justNow",
224
+ n: 0
225
+ };
226
+ if (diff < HOUR_MS) return {
227
+ key: "time.minutes",
228
+ n: Math.floor(diff / MINUTE_MS)
229
+ };
230
+ if (diff < DAY_MS) return {
231
+ key: "time.hours",
232
+ n: Math.floor(diff / HOUR_MS)
233
+ };
234
+ return {
235
+ key: "time.days",
236
+ n: Math.floor(diff / DAY_MS)
237
+ };
238
+ }
239
+ /**
240
+ * Map a harness end-reason string to a stable i18n key.
241
+ * @param kind - the raw end-reason string (e.g. 'max-tokens').
242
+ * @returns the i18n key, or the raw kind unchanged when unknown.
243
+ */
244
+ function reasonKeyOf(kind) {
245
+ switch (kind) {
246
+ case "completed": return "reason.completed";
247
+ case "aborted": return "reason.aborted";
248
+ case "error": return "reason.error";
249
+ case "max-tokens": return "reason.maxTokens";
250
+ case "interrupted": return "reason.interrupted";
251
+ case "blocked": return "reason.blocked";
252
+ default: return kind;
253
+ }
254
+ }
255
+ //#endregion
256
+ //#region src/client/tooltip-logic.ts
257
+ const EMPTY_META = {
258
+ model: null,
259
+ purpose: null,
260
+ inputTokens: null,
261
+ outputTokens: null
262
+ };
263
+ /** True when the value is a plain (non-array, non-null) object. */
264
+ function isRecord(value) {
265
+ return value !== null && typeof value === "object" && !Array.isArray(value);
266
+ }
267
+ /**
268
+ * Decode a `usage` payload structurally: only numeric `inputTokens` /
269
+ * `outputTokens` survive; anything else (absent, malformed, wrong types)
270
+ * degrades to null — the boundary owns trust, the callers get plain numbers.
271
+ * @param usage - untrusted usage payload (typed `unknown` at runtime).
272
+ * @returns the token counts with null for every missing/malformed field.
273
+ */
274
+ function decodeUsage(usage) {
275
+ if (!isRecord(usage)) return {
276
+ inputTokens: null,
277
+ outputTokens: null
278
+ };
279
+ return {
280
+ inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
281
+ outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null
282
+ };
283
+ }
284
+ /** Resolve model/purpose from a request config, falling back to provenance. */
285
+ function metaFromRecord(record) {
286
+ return {
287
+ model: record.requestConfig?.model ?? record.provenance?.model ?? null,
288
+ purpose: record.requestConfig?.purpose ?? null,
289
+ ...decodeUsage(record.usage)
290
+ };
291
+ }
292
+ /**
293
+ * Derive the hover metadata for one turn. Sources, in priority order:
294
+ * 1. the `assistant-step` chat node(s) of the turn — their `data.finalNode`
295
+ * carries the recorded `requestConfig` / `provenance` / `usage`;
296
+ * 2. `trajectoryRequests` — the latest entry whose `turn` matches (used when
297
+ * no assistant-step node yields a model or purpose);
298
+ * 3. all-null when the turn is absent, no node matches, or everything is
299
+ * malformed. Never throws.
300
+ * @param nodes - stable per-key chat node reader (as exposed by the snapshot).
301
+ * @param locations - turn -> ordered node keys index.
302
+ * @param turn - owning turn; undefined yields all-null.
303
+ * @param trajectoryRequests - optional fallback request log.
304
+ * @returns the turn's metadata, null where unknown.
305
+ */
306
+ function deriveTurnMeta(nodes, locations, turn, trajectoryRequests) {
307
+ if (turn === void 0) return EMPTY_META;
308
+ for (const key of locations.getTurn(turn)) {
309
+ const node = nodes.get(key);
310
+ if (node === void 0 || node.kind !== "assistant-step") continue;
311
+ const finalNode = (isRecord(node.data) ? node.data : void 0)?.finalNode;
312
+ if (!isRecord(finalNode)) continue;
313
+ const meta = metaFromRecord(finalNode);
314
+ if (meta.model !== null || meta.purpose !== null) return meta;
315
+ }
316
+ if (trajectoryRequests !== void 0) {
317
+ let latest;
318
+ for (const request of trajectoryRequests) if (request.turn === turn) latest = request;
319
+ if (latest !== void 0) return metaFromRecord(latest);
320
+ }
321
+ return EMPTY_META;
322
+ }
323
+ //#endregion
129
324
  //#region src/client/rail-keyboard.ts
130
325
  /**
131
326
  * Pure roving-tabindex index math for the milestone rail.
@@ -242,17 +437,83 @@ window.__ModuleLoader__.load({
242
437
  return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
243
438
  }
244
439
  //#endregion
440
+ //#region src/client/turn-group-logic.ts
441
+ /**
442
+ * Partition consecutive marks by turn. Marks with the same numeric turn that
443
+ * appear one after another share a group; each mark with `turn === undefined`
444
+ * becomes its own singleton group with `turn: null`.
445
+ * @param marks - marks in rail order.
446
+ * @returns the groups, in original order, partitioning `marks` exactly.
447
+ */
448
+ function buildTurnGroups(marks) {
449
+ const groups = [];
450
+ let current;
451
+ for (const mark of marks) {
452
+ if (mark.turn === void 0) {
453
+ current = void 0;
454
+ groups.push({
455
+ turn: null,
456
+ marks: [mark]
457
+ });
458
+ continue;
459
+ }
460
+ if (current !== void 0 && current.turn === mark.turn) current.marks.push(mark);
461
+ else {
462
+ current = {
463
+ turn: mark.turn,
464
+ marks: [mark]
465
+ };
466
+ groups.push(current);
467
+ }
468
+ }
469
+ return groups;
470
+ }
471
+ /**
472
+ * Flatten groups into render items, collapsing collapsed turns to their last
473
+ * mark and reporting where separators belong.
474
+ * @param groups - groups from {@link buildTurnGroups} (they partition the
475
+ * original marks array in order, so a running count yields original indices).
476
+ * @param collapsed - turns whose group should collapse to its LAST mark.
477
+ * @returns `items` (one RenderItem per visible dot, in group order) and
478
+ * `separatorsAt` (the index in `items` before which a separator should be
479
+ * inserted at each non-first group boundary; never includes 0).
480
+ */
481
+ function buildRenderList(groups, collapsed) {
482
+ const items = [];
483
+ const separatorsAt = [];
484
+ let counter = 0;
485
+ for (const group of groups) {
486
+ const startIndex = items.length;
487
+ if (group.turn !== null && collapsed.has(group.turn) && group.marks.length > 1) {
488
+ const last = group.marks[group.marks.length - 1];
489
+ items.push({
490
+ mark: last,
491
+ displayIndex: counter + group.marks.length - 1
492
+ });
493
+ } else for (let i = 0; i < group.marks.length; i++) items.push({
494
+ mark: group.marks[i],
495
+ displayIndex: counter + i
496
+ });
497
+ counter += group.marks.length;
498
+ if (startIndex > 0) separatorsAt.push(startIndex);
499
+ }
500
+ return {
501
+ items,
502
+ separatorsAt
503
+ };
504
+ }
505
+ //#endregion
245
506
  //#region src/client/MilestoneRailSearch.tsx
246
507
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
247
- const DOT_HIT$1 = 22;
508
+ const DOT_HIT$1 = 28;
248
509
  /**
249
510
  * @param props - the search state slice plus the rail's event handlers.
250
511
  */
251
- function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear }) {
512
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
252
513
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
253
514
  type: "button",
254
515
  "data-search-toggle": true,
255
- "aria-label": "搜索消息",
516
+ "aria-label": t("search.label"),
256
517
  "aria-pressed": panelOpen,
257
518
  onClick: onToggle,
258
519
  style: {
@@ -269,8 +530,8 @@ window.__ModuleLoader__.load({
269
530
  color: panelOpen ? "#9db8ff" : "#8b96ab"
270
531
  },
271
532
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
272
- width: "13",
273
- height: "13",
533
+ width: "16",
534
+ height: "16",
274
535
  viewBox: "0 0 24 24",
275
536
  fill: "none",
276
537
  stroke: "currentColor",
@@ -288,7 +549,7 @@ window.__ModuleLoader__.load({
288
549
  position: "fixed",
289
550
  top: panelTop,
290
551
  right: panelRight,
291
- width: 220,
552
+ width: "min(220px, calc(100vw - 48px))",
292
553
  padding: "10px 12px",
293
554
  background: "rgba(20, 24, 32, 0.97)",
294
555
  color: "#e6e8ee",
@@ -304,8 +565,8 @@ window.__ModuleLoader__.load({
304
565
  },
305
566
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
306
567
  "data-rail-search": true,
307
- "aria-label": "搜索消息",
308
- placeholder: "搜索消息内容",
568
+ "aria-label": t("search.label"),
569
+ placeholder: t("search.placeholder"),
309
570
  value: query,
310
571
  onChange: (e) => onQueryChange(e.target.value),
311
572
  onKeyDown: onSearchKeyDown,
@@ -313,8 +574,9 @@ window.__ModuleLoader__.load({
313
574
  style: {
314
575
  flex: 1,
315
576
  minWidth: 0,
316
- padding: "5px 8px",
317
- fontSize: 12,
577
+ padding: "6px 10px",
578
+ fontSize: 14,
579
+ lineHeight: 1.4,
318
580
  color: "#e6e8ee",
319
581
  background: "rgba(255, 255, 255, 0.08)",
320
582
  border: "1px solid rgba(255, 255, 255, 0.16)",
@@ -324,7 +586,7 @@ window.__ModuleLoader__.load({
324
586
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
325
587
  type: "button",
326
588
  "data-search-clear": true,
327
- "aria-label": "清空搜索",
589
+ "aria-label": t("search.clear"),
328
590
  onClick: onClear,
329
591
  style: {
330
592
  width: 22,
@@ -340,8 +602,8 @@ window.__ModuleLoader__.load({
340
602
  color: "#8b96ab"
341
603
  },
342
604
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
343
- width: "10",
344
- height: "10",
605
+ width: "12",
606
+ height: "12",
345
607
  viewBox: "0 0 24 24",
346
608
  fill: "none",
347
609
  stroke: "currentColor",
@@ -355,7 +617,7 @@ window.__ModuleLoader__.load({
355
617
  "data-match-count": true,
356
618
  style: {
357
619
  marginTop: 6,
358
- fontSize: 11,
620
+ fontSize: 13,
359
621
  color: "#8b96ab"
360
622
  },
361
623
  children: [
@@ -367,11 +629,109 @@ window.__ModuleLoader__.load({
367
629
  })] });
368
630
  }
369
631
  //#endregion
632
+ //#region src/client/MilestoneListPanel.tsx
633
+ /**
634
+ * @param props - the panel anchor, the full marks array, and the rail's jump handler.
635
+ */
636
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, t }) {
637
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
638
+ "data-milestone-list": true,
639
+ style: {
640
+ position: "fixed",
641
+ top: panelTop,
642
+ right: panelRight,
643
+ width: "min(280px, calc(100vw - 48px))",
644
+ padding: "10px 12px",
645
+ background: "rgba(20, 24, 32, 0.97)",
646
+ color: "#e6e8ee",
647
+ borderRadius: 8,
648
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
649
+ zIndex: 103
650
+ },
651
+ children: [
652
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-list-item]:hover { background: rgba(77, 124, 254, 0.18); }` }),
653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
654
+ style: {
655
+ display: "flex",
656
+ alignItems: "center",
657
+ gap: 6,
658
+ marginBottom: 6
659
+ },
660
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
661
+ style: {
662
+ fontSize: 13,
663
+ fontWeight: 600,
664
+ color: "#e6e8ee"
665
+ },
666
+ children: t("list.label")
667
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
668
+ style: {
669
+ fontSize: 12,
670
+ color: "#8b96ab"
671
+ },
672
+ children: marks.length
673
+ })]
674
+ }),
675
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
676
+ style: {
677
+ maxHeight: 300,
678
+ overflowY: "auto",
679
+ display: "flex",
680
+ flexDirection: "column",
681
+ gap: 2
682
+ },
683
+ children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
684
+ type: "button",
685
+ "data-list-item": true,
686
+ "data-jump-key": mark.key,
687
+ onClick: () => onJump(mark.key),
688
+ title: mark.preview,
689
+ style: {
690
+ display: "block",
691
+ width: "100%",
692
+ minWidth: 0,
693
+ padding: "6px 8px",
694
+ background: "transparent",
695
+ border: "none",
696
+ borderRadius: 6,
697
+ cursor: "pointer",
698
+ color: "#e6e8ee",
699
+ textAlign: "left"
700
+ },
701
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
702
+ style: {
703
+ fontSize: 12,
704
+ color: "#8b96ab",
705
+ whiteSpace: "nowrap"
706
+ },
707
+ children: [t("pos.of", {
708
+ n: i + 1,
709
+ m: marks.length
710
+ }), mark.turn !== void 0 ? ` · ${t("turn.label", { n: mark.turn })}` : null]
711
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
712
+ style: {
713
+ fontSize: 13,
714
+ lineHeight: 1.4,
715
+ overflow: "hidden",
716
+ textOverflow: "ellipsis",
717
+ whiteSpace: "nowrap"
718
+ },
719
+ children: mark.preview || t("no.text")
720
+ })]
721
+ }, mark.key))
722
+ })
723
+ ]
724
+ });
725
+ }
726
+ //#endregion
370
727
  //#region src/client/MilestoneRailTooltip.tsx
371
728
  /**
372
729
  * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
373
730
  */
374
- function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onMouseEnter, onMouseLeave, panelRight }) {
731
+ function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onCopy, onFork, copied, forked, turnCollapsed, onToggleCollapse, onMouseEnter, onMouseLeave, panelRight, t }) {
732
+ const relativeTime = relativeTimeParts(hover.mark.time, Date.now());
733
+ const turn = hover.mark.turn;
734
+ const showCollapse = turn !== void 0 && hover.turnMarkCount !== null && hover.turnMarkCount > 1;
375
735
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
376
736
  onMouseEnter,
377
737
  onMouseLeave,
@@ -380,14 +740,14 @@ window.__ModuleLoader__.load({
380
740
  right: panelRight,
381
741
  top: hover.top,
382
742
  transform: "translateY(-50%)",
383
- maxWidth: 300,
743
+ maxWidth: "min(300px, calc(100vw - 120px))",
384
744
  minWidth: 180,
385
745
  padding: "8px 12px",
386
746
  background: "rgba(20, 24, 32, 0.96)",
387
747
  color: "#e6e8ee",
388
748
  borderRadius: 8,
389
- fontSize: 12,
390
- lineHeight: 1.6,
749
+ fontSize: "var(--dsw-font-s-14, 14px)",
750
+ lineHeight: 1.5,
391
751
  whiteSpace: "pre-wrap",
392
752
  wordBreak: "break-word",
393
753
  boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
@@ -399,24 +759,23 @@ window.__ModuleLoader__.load({
399
759
  style: {
400
760
  display: "flex",
401
761
  alignItems: "center",
762
+ flexWrap: "wrap",
402
763
  gap: 8,
403
764
  color: "#9aa4b8",
404
- fontSize: 11,
765
+ fontSize: 13,
766
+ lineHeight: 1.4,
405
767
  marginBottom: 4
406
768
  },
407
769
  children: [
408
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
409
- "第 ",
410
- hover.index + 1,
411
- " / ",
412
- hover.total,
413
- " 条"
414
- ] }),
770
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pos.of", {
771
+ n: hover.index + 1,
772
+ m: hover.total
773
+ }) }),
415
774
  hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
416
775
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
417
776
  type: "button",
418
777
  "data-star": true,
419
- "aria-label": "收藏此消息",
778
+ "aria-label": t("bookmark.star"),
420
779
  "aria-pressed": bookmarked,
421
780
  "data-starred": bookmarked ? "true" : void 0,
422
781
  onClick: (e) => {
@@ -438,8 +797,8 @@ window.__ModuleLoader__.load({
438
797
  color: bookmarked ? "#ffd166" : "#8b96ab"
439
798
  },
440
799
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
441
- width: "13",
442
- height: "13",
800
+ width: "14",
801
+ height: "14",
443
802
  viewBox: "0 0 24 24",
444
803
  fill: bookmarked ? "currentColor" : "none",
445
804
  stroke: "currentColor",
@@ -448,12 +807,79 @@ window.__ModuleLoader__.load({
448
807
  "aria-hidden": "true",
449
808
  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
809
  })
810
+ }),
811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
812
+ type: "button",
813
+ "data-copy-message": true,
814
+ "data-copied": copied ? "true" : void 0,
815
+ onClick: (e) => {
816
+ e.stopPropagation();
817
+ onCopy(hover.mark);
818
+ },
819
+ style: {
820
+ flexShrink: 0,
821
+ display: "flex",
822
+ alignItems: "center",
823
+ justifyContent: "center",
824
+ background: "transparent",
825
+ border: "none",
826
+ padding: "3px 8px",
827
+ cursor: "pointer",
828
+ whiteSpace: "nowrap",
829
+ color: copied ? "#7ee2a8" : "#8b96ab"
830
+ },
831
+ children: t("copy.message")
832
+ }),
833
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
834
+ type: "button",
835
+ "data-fork-here": true,
836
+ "data-forked": forked ? "true" : void 0,
837
+ onClick: (e) => {
838
+ e.stopPropagation();
839
+ onFork(hover.mark);
840
+ },
841
+ style: {
842
+ flexShrink: 0,
843
+ display: "flex",
844
+ alignItems: "center",
845
+ justifyContent: "center",
846
+ background: "transparent",
847
+ border: "none",
848
+ padding: "3px 8px",
849
+ cursor: "pointer",
850
+ whiteSpace: "nowrap",
851
+ color: forked ? "#7ee2a8" : "#8b96ab"
852
+ },
853
+ children: t("fork.here")
854
+ }),
855
+ showCollapse && turn !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
856
+ type: "button",
857
+ "data-toggle-collapse": true,
858
+ "aria-pressed": turnCollapsed,
859
+ "data-collapsed": turnCollapsed ? "true" : void 0,
860
+ onClick: (e) => {
861
+ e.stopPropagation();
862
+ onToggleCollapse(turn);
863
+ },
864
+ style: {
865
+ flexShrink: 0,
866
+ display: "flex",
867
+ alignItems: "center",
868
+ justifyContent: "center",
869
+ background: "transparent",
870
+ border: "none",
871
+ padding: "3px 8px",
872
+ cursor: "pointer",
873
+ whiteSpace: "nowrap",
874
+ color: turnCollapsed ? "#7ee2a8" : "#8b96ab"
875
+ },
876
+ children: turnCollapsed ? t("expand.turn") : t("collapse.turn")
451
877
  })
452
878
  ]
453
879
  }),
454
880
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
455
881
  style: { color: "#c7cede" },
456
- children: hover.mark.preview !== "" ? hover.mark.preview : "(无文本)"
882
+ children: hover.mark.preview !== "" ? hover.mark.preview : t("no.text")
457
883
  }),
458
884
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
459
885
  style: {
@@ -461,27 +887,224 @@ window.__ModuleLoader__.load({
461
887
  flexWrap: "wrap",
462
888
  gap: 8,
463
889
  color: "#8b96ab",
464
- fontSize: 11,
890
+ fontSize: 13,
891
+ lineHeight: 1.4,
465
892
  marginTop: 4
466
893
  },
467
894
  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] }),
895
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(relativeTime.key, { n: relativeTime.n }) }),
896
+ hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("duration.label", { name: hover.durationLabel }) }),
470
897
  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] }),
898
+ hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ttft.label", { name: hover.ttftLabel }) }),
472
899
  hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
473
900
  ]
901
+ }),
902
+ (hover.modelLabel !== null || hover.purposeLabel !== null || hover.tokensLabel !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
903
+ style: {
904
+ display: "flex",
905
+ flexWrap: "wrap",
906
+ gap: 8,
907
+ color: "#8b96ab",
908
+ fontSize: 13,
909
+ lineHeight: 1.4,
910
+ marginTop: 4
911
+ },
912
+ children: [
913
+ hover.modelLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
914
+ "data-model": hover.modelLabel,
915
+ children: hover.modelLabel
916
+ }),
917
+ hover.purposeLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
918
+ "data-purpose": hover.purposeLabel,
919
+ children: hover.purposeLabel
920
+ }),
921
+ hover.tokensLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
922
+ "data-tokens": hover.tokensLabel,
923
+ children: hover.tokensLabel
924
+ })
925
+ ]
474
926
  })
475
927
  ]
476
928
  });
477
929
  }
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)} 天前`;
930
+ //#endregion
931
+ //#region src/client/MilestoneSessionSearch.tsx
932
+ /**
933
+ * MilestoneSessionSearch: the cross-session search panel (P3) the fixed
934
+ * chrome pinned to the rail's top that searches EVERY session's message
935
+ * content through the injected `searchSessions` action (the harness
936
+ * `session.search` RPC), lists ranked hits (display title + snippet), and
937
+ * opens the clicked session via `openSession` (the same selection path the
938
+ * sidebar uses).
939
+ *
940
+ * Owns its search lifecycle — debounce + AbortController + status — unlike
941
+ * the rail's in-session search (RailSearchUi), which is a pure presentation
942
+ * slice of MilestoneRail's own state. Mirrors MilestoneListPanel's
943
+ * fixed-panel styling. Renders one DOM contract
944
+ * (`data-session-search` root / `data-session-search-input` /
945
+ * `data-session-search-result` rows / `data-session-search-error` /
946
+ * `data-session-search-more`).
947
+ */
948
+ /** Debounce window for the cross-session query (ms). */
949
+ const SEARCH_DEBOUNCE_MS = 250;
950
+ /**
951
+ * @param props - the panel anchor, the close/open/search actions, and the locale interpreter.
952
+ */
953
+ function MilestoneSessionSearch({ panelTop, panelRight, onClose, searchSessions, openSession, t }) {
954
+ const [query, setQuery] = (0, react.useState)("");
955
+ const [status, setStatus] = (0, react.useState)("idle");
956
+ const [hits, setHits] = (0, react.useState)([]);
957
+ const [hasMore, setHasMore] = (0, react.useState)(false);
958
+ (0, react.useEffect)(() => {
959
+ const trimmed = query.trim();
960
+ if (trimmed === "") {
961
+ setStatus("idle");
962
+ return;
963
+ }
964
+ const controller = new AbortController();
965
+ setStatus("loading");
966
+ const timer = window.setTimeout(() => {
967
+ searchSessions(trimmed, controller.signal).then((result) => {
968
+ if (controller.signal.aborted) return;
969
+ setHits(result.items);
970
+ setHasMore(result.hasMore);
971
+ setStatus(result.items.length > 0 ? "results" : "empty");
972
+ }, () => {
973
+ if (controller.signal.aborted) return;
974
+ setStatus("error");
975
+ });
976
+ }, SEARCH_DEBOUNCE_MS);
977
+ return () => {
978
+ window.clearTimeout(timer);
979
+ controller.abort();
980
+ };
981
+ }, [query, searchSessions]);
982
+ const onInputKeyDown = (e) => {
983
+ if (e.key === "Escape") onClose();
984
+ };
985
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
986
+ "data-session-search": true,
987
+ style: {
988
+ position: "fixed",
989
+ top: panelTop,
990
+ right: panelRight,
991
+ width: "min(280px, calc(100vw - 48px))",
992
+ padding: "10px 12px",
993
+ background: "rgba(20, 24, 32, 0.97)",
994
+ color: "#e6e8ee",
995
+ borderRadius: 8,
996
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
997
+ zIndex: 103
998
+ },
999
+ children: [
1000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-session-search-result]:hover { background: rgba(77, 124, 254, 0.18); }` }),
1001
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1002
+ style: {
1003
+ display: "flex",
1004
+ alignItems: "center",
1005
+ gap: 6,
1006
+ marginBottom: 6
1007
+ },
1008
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1009
+ style: {
1010
+ fontSize: 13,
1011
+ fontWeight: 600,
1012
+ color: "#e6e8ee"
1013
+ },
1014
+ children: t("search.cross")
1015
+ })
1016
+ }),
1017
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1018
+ "data-session-search-input": true,
1019
+ "aria-label": t("search.cross"),
1020
+ placeholder: t("search.cross"),
1021
+ value: query,
1022
+ onChange: (e) => setQuery(e.target.value),
1023
+ onKeyDown: onInputKeyDown,
1024
+ autoFocus: true,
1025
+ style: {
1026
+ boxSizing: "border-box",
1027
+ width: "100%",
1028
+ padding: "6px 10px",
1029
+ fontSize: 14,
1030
+ lineHeight: 1.4,
1031
+ color: "#e6e8ee",
1032
+ background: "rgba(255, 255, 255, 0.08)",
1033
+ border: "1px solid rgba(255, 255, 255, 0.16)",
1034
+ borderRadius: 6,
1035
+ outline: "none"
1036
+ }
1037
+ }),
1038
+ status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1039
+ "data-session-search-error": true,
1040
+ style: {
1041
+ marginTop: 8,
1042
+ fontSize: 13,
1043
+ color: "#f07c7c"
1044
+ },
1045
+ children: t("search.error")
1046
+ }),
1047
+ status === "results" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1048
+ style: {
1049
+ maxHeight: 300,
1050
+ overflowY: "auto",
1051
+ display: "flex",
1052
+ flexDirection: "column",
1053
+ gap: 2,
1054
+ marginTop: 8
1055
+ },
1056
+ children: hits.map((hit) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1057
+ type: "button",
1058
+ "data-session-search-result": true,
1059
+ onClick: () => {
1060
+ openSession(hit.sessionId);
1061
+ onClose();
1062
+ },
1063
+ title: hit.snippet,
1064
+ style: {
1065
+ display: "block",
1066
+ width: "100%",
1067
+ minWidth: 0,
1068
+ padding: "6px 8px",
1069
+ background: "transparent",
1070
+ border: "none",
1071
+ borderRadius: 6,
1072
+ cursor: "pointer",
1073
+ color: "#e6e8ee",
1074
+ textAlign: "left"
1075
+ },
1076
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1077
+ style: {
1078
+ fontSize: 12,
1079
+ color: "#9db8ff",
1080
+ whiteSpace: "nowrap",
1081
+ overflow: "hidden",
1082
+ textOverflow: "ellipsis"
1083
+ },
1084
+ children: hit.title ?? t("search.untitled")
1085
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1086
+ style: {
1087
+ fontSize: 13,
1088
+ lineHeight: 1.4,
1089
+ color: "#c6ccd8",
1090
+ overflow: "hidden",
1091
+ textOverflow: "ellipsis",
1092
+ whiteSpace: "nowrap"
1093
+ },
1094
+ children: hit.snippet
1095
+ })]
1096
+ }, hit.sessionId))
1097
+ }), hasMore && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1098
+ "data-session-search-more": true,
1099
+ style: {
1100
+ marginTop: 6,
1101
+ fontSize: 12,
1102
+ color: "#8b96ab"
1103
+ },
1104
+ children: t("search.more")
1105
+ })] })
1106
+ ]
1107
+ });
485
1108
  }
486
1109
  //#endregion
487
1110
  //#region src/client/useCurrentAnchor.ts
@@ -598,15 +1221,38 @@ window.__ModuleLoader__.load({
598
1221
  70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
599
1222
  100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
600
1223
  }`;
1224
+ /**
1225
+ * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
1226
+ * conversation reads cleaner. The rule targets the stable, un-hashed
1227
+ * `data-variant="think"` attribute on the thinking-block ROOT (the harness
1228
+ * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
1229
+ * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
1230
+ * opening it, `[data-open]`) restores full opacity. Kept in an inline
1231
+ * <style> so the plugin stays zero-asset — same pattern as BADGE_PULSE_CSS.
1232
+ */
1233
+ const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
1234
+ [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
601
1235
  /** Visual dot diameter (px). */
602
- const DOT_SIZE = 12;
1236
+ const DOT_SIZE = 14;
603
1237
  /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
604
- const DOT_HIT = 22;
1238
+ const DOT_HIT = 28;
605
1239
  /** Vertical gap between dot hit areas (px) — fixed pitch, never scaled. */
606
- const DOT_GAP = 12;
1240
+ const DOT_GAP = 14;
607
1241
  /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
608
1242
  const RAIL_INSET = 14;
609
1243
  /**
1244
+ * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
1245
+ * deep-link attempt — the harness scrolls the conversation to the bottom on
1246
+ * load, so the deep link must land AFTER the view mounts.
1247
+ */
1248
+ const DEEP_LINK_INITIAL_DELAY = 100;
1249
+ /** P3: interval between DOM-row polls while waiting for the target to render. */
1250
+ const DEEP_LINK_POLL_DELAY = 150;
1251
+ /** P3: polls before falling back to a single `loadOlder` fetch. */
1252
+ const DEEP_LINK_MAX_POLLS = 5;
1253
+ /** P3: bounded polls after `loadOlder`, then the deep link gives up silently. */
1254
+ const DEEP_LINK_MAX_RETRY_POLLS = 5;
1255
+ /**
610
1256
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
611
1257
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
612
1258
  */
@@ -624,18 +1270,6 @@ window.__ModuleLoader__.load({
624
1270
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
625
1271
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
626
1272
  }
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
1273
  /** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
640
1274
  function turnTailOf(turn) {
641
1275
  const data = turn.data;
@@ -644,13 +1278,21 @@ window.__ModuleLoader__.load({
644
1278
  }
645
1279
  /**
646
1280
  * @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).
1281
+ * the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
1282
+ * actions, injected by the framework from the declared store seat), and the
1283
+ * framework-synthesized `t` locale interpreter (registered via the entry's
1284
+ * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
1285
+ * outside the slot machinery).
649
1286
  */
650
- function MilestoneRail({ useSession, loadOlder, useStore, actions }) {
1287
+ function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1288
+ items: [],
1289
+ hasMore: false
1290
+ }), openSession = () => {}, t = (key) => key }) {
651
1291
  const order = useSession((s) => s.chat.order);
652
1292
  const nodes = useSession((s) => s.chat.nodes);
1293
+ const locations = useSession((s) => s.chat.locations);
653
1294
  const timeline = useSession((s) => s.chat.timeline);
1295
+ const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
654
1296
  const hasMore = useSession((s) => s.hasMore);
655
1297
  const loadingOlder = useSession((s) => s.loadingOlder);
656
1298
  const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
@@ -696,9 +1338,33 @@ window.__ModuleLoader__.load({
696
1338
  panelOpen: false
697
1339
  });
698
1340
  const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
1341
+ const [focusActive, setFocusActive] = (0, react.useState)(false);
1342
+ const [listOpen, setListOpen] = (0, react.useState)(false);
1343
+ const [crossOpen, setCrossOpen] = (0, react.useState)(false);
1344
+ const [copiedKey, setCopiedKey] = (0, react.useState)(null);
1345
+ const [forkedKey, setForkedKey] = (0, react.useState)(null);
1346
+ const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
699
1347
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
700
1348
  const listRef = (0, react.useRef)(null);
701
1349
  const currentKey = useCurrentAnchor(order);
1350
+ /**
1351
+ * P3: jump to the chat row with the given node key — smooth-scroll it into
1352
+ * view and write the position back into the URL hash (`#msg=<key>`) so
1353
+ * refresh and share preserve it. `history.replaceState` (not a
1354
+ * `location.hash` assignment) keeps the history stack clean, and it never
1355
+ * fires `hashchange`, so the deep-link listeners below never echo the
1356
+ * rail's own updates. No-op when the row is not (yet) rendered — the
1357
+ * deep-link mount retry and the load-older flow cover that case.
1358
+ */
1359
+ const jump = (key) => {
1360
+ const row = findRow(key);
1361
+ if (row === null) return;
1362
+ row.scrollIntoView({
1363
+ behavior: "smooth",
1364
+ block: "start"
1365
+ });
1366
+ history.replaceState(null, "", buildMessageHash(key));
1367
+ };
702
1368
  const displayMarks = (0, react.useMemo)(() => {
703
1369
  if (!bookmarksOnly) return marks;
704
1370
  return filterByBookmarks(marks, bookmarkedKeys).visible.map((i) => marks[i]);
@@ -710,6 +1376,22 @@ window.__ModuleLoader__.load({
710
1376
  const { matches } = (0, react.useMemo)(() => filterMarks(displayMarks, search.query), [displayMarks, search.query]);
711
1377
  const hasQuery = search.query.trim() !== "";
712
1378
  const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
1379
+ const groups = (0, react.useMemo)(() => buildTurnGroups(displayMarks), [displayMarks]);
1380
+ const render = (0, react.useMemo)(() => buildRenderList(groups, collapsedTurns), [groups, collapsedTurns]);
1381
+ const separatorIndices = (0, react.useMemo)(() => new Set(render.separatorsAt), [render]);
1382
+ const collapsedSummaries = (0, react.useMemo)(() => {
1383
+ const summaries = /* @__PURE__ */ new Map();
1384
+ 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);
1385
+ return summaries;
1386
+ }, [groups, collapsedTurns]);
1387
+ const turnMarkCounts = (0, react.useMemo)(() => {
1388
+ const counts = /* @__PURE__ */ new Map();
1389
+ for (const mark of displayMarks) {
1390
+ if (mark.turn === void 0) continue;
1391
+ counts.set(mark.turn, (counts.get(mark.turn) ?? 0) + 1);
1392
+ }
1393
+ return counts;
1394
+ }, [displayMarks]);
713
1395
  (0, react.useLayoutEffect)(() => {
714
1396
  if (marks.length < MIN_MARKS) {
715
1397
  setRailBox(null);
@@ -735,15 +1417,61 @@ window.__ModuleLoader__.load({
735
1417
  };
736
1418
  }, [marks.length]);
737
1419
  (0, react.useLayoutEffect)(() => {
738
- setFocusIndex((f) => clampIndex(f, displayMarks.length));
739
- }, [displayMarks.length]);
1420
+ setFocusIndex((f) => clampIndex(f, render.items.length));
1421
+ }, [render.items.length]);
1422
+ (0, react.useEffect)(() => {
1423
+ if (!listOpen && !crossOpen) return;
1424
+ const onKey = (e) => {
1425
+ if (e.key !== "Escape") return;
1426
+ setListOpen(false);
1427
+ setCrossOpen(false);
1428
+ };
1429
+ window.addEventListener("keydown", onKey);
1430
+ return () => window.removeEventListener("keydown", onKey);
1431
+ }, [listOpen, crossOpen]);
1432
+ const marksRef = (0, react.useRef)(marks);
1433
+ (0, react.useEffect)(() => {
1434
+ marksRef.current = marks;
1435
+ });
1436
+ (0, react.useEffect)(() => {
1437
+ const key = parseDeepLinkHash(window.location.hash);
1438
+ if (key === null) return;
1439
+ let cancelled = false;
1440
+ let timer;
1441
+ const attempt = (pollsLeft, canLoadOlder) => {
1442
+ if (cancelled) return;
1443
+ if (findRow(key) !== null) {
1444
+ jump(key);
1445
+ return;
1446
+ }
1447
+ if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
1448
+ if (pollsLeft > 0) {
1449
+ timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
1450
+ return;
1451
+ }
1452
+ if (canLoadOlder) {
1453
+ loadOlder().then(() => {
1454
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_RETRY_POLLS, false), DEEP_LINK_POLL_DELAY);
1455
+ }, () => {});
1456
+ return;
1457
+ }
1458
+ };
1459
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_POLLS, true), DEEP_LINK_INITIAL_DELAY);
1460
+ return () => {
1461
+ cancelled = true;
1462
+ if (timer !== void 0) window.clearTimeout(timer);
1463
+ };
1464
+ }, []);
1465
+ (0, react.useEffect)(() => {
1466
+ const onHashChange = () => {
1467
+ const key = parseDeepLinkHash(window.location.hash);
1468
+ if (key === null) return;
1469
+ if (marksRef.current.some((m) => m.key === key)) jump(key);
1470
+ };
1471
+ window.addEventListener("hashchange", onHashChange);
1472
+ return () => window.removeEventListener("hashchange", onHashChange);
1473
+ }, []);
740
1474
  if (railBox === null || marks.length < MIN_MARKS) return null;
741
- const jump = (key) => {
742
- findRow(key)?.scrollIntoView({
743
- behavior: "smooth",
744
- block: "start"
745
- });
746
- };
747
1475
  const updateQuery = (query) => {
748
1476
  setSearch({
749
1477
  query,
@@ -786,16 +1514,17 @@ window.__ModuleLoader__.load({
786
1514
  /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
787
1515
  const onListFocus = (e) => {
788
1516
  if (e.target !== e.currentTarget) return;
789
- focusDotAt(clampIndex(focusIndex, displayMarks.length));
1517
+ focusDotAt(clampIndex(focusIndex, render.items.length));
790
1518
  };
791
1519
  /**
792
1520
  * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
793
1521
  * jump to first/last. Enter/Space are deliberately NOT handled — the dots
794
1522
  * are real buttons, so native activation fires the jump click untouched
795
- * (preventDefault here would swallow it).
1523
+ * (preventDefault here would swallow it). The rover counts RENDERED dots
1524
+ * (collapsed turns shrink the list).
796
1525
  */
797
1526
  const onListKeyDown = (e) => {
798
- const count = displayMarks.length;
1527
+ const count = render.items.length;
799
1528
  let next = null;
800
1529
  switch (e.key) {
801
1530
  case "ArrowDown":
@@ -818,6 +1547,8 @@ window.__ModuleLoader__.load({
818
1547
  focusDotAt(target);
819
1548
  };
820
1549
  const buildHover = (mark, index) => {
1550
+ if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
1551
+ if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
821
1552
  const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
822
1553
  let durationLabel = null;
823
1554
  let reasonLabel = null;
@@ -827,7 +1558,7 @@ window.__ModuleLoader__.load({
827
1558
  if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
828
1559
  if (turn.end !== void 0) {
829
1560
  const reason = turn.end.data.reason;
830
- if (reason?.kind !== void 0) reasonLabel = reasonLabelOf(reason.kind);
1561
+ if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
831
1562
  }
832
1563
  const tail = turnTailOf(turn);
833
1564
  if (tail !== void 0) {
@@ -835,18 +1566,36 @@ window.__ModuleLoader__.load({
835
1566
  if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
836
1567
  }
837
1568
  }
1569
+ const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
838
1570
  return {
839
1571
  mark,
840
1572
  index,
841
1573
  total: displayMarks.length,
842
- turnLabel: mark.turn !== void 0 ? `第 ${mark.turn} 轮` : null,
1574
+ turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
843
1575
  durationLabel,
844
1576
  reasonLabel,
845
1577
  ttftLabel,
846
- tpsLabel
1578
+ tpsLabel,
1579
+ modelLabel: meta.model,
1580
+ purposeLabel: meta.purpose,
1581
+ tokensLabel: meta.inputTokens !== null && meta.outputTokens !== null ? `${meta.inputTokens} / ${meta.outputTokens} tok` : null,
1582
+ turnMarkCount: mark.turn !== void 0 ? turnMarkCounts.get(mark.turn) ?? 0 : null
847
1583
  };
848
1584
  };
849
1585
  /**
1586
+ * C4: collapse/expand the hovered mark's turn in the rail. The set is
1587
+ * replaced immutably (a turn toggles out when already present); collapsing
1588
+ * keeps the turn's LAST mark visible via buildRenderList.
1589
+ */
1590
+ const onToggleCollapse = (turn) => {
1591
+ setCollapsedTurns((prev) => {
1592
+ const next = new Set(prev);
1593
+ if (next.has(turn)) next.delete(turn);
1594
+ else next.add(turn);
1595
+ return next;
1596
+ });
1597
+ };
1598
+ /**
850
1599
  * T10: flip a mark's bookmark in the persisted store. The store action is
851
1600
  * the write path (the engine persists synchronously). The hover re-assert
852
1601
  * forces a re-render so the star reflects the toggled state — production
@@ -858,6 +1607,20 @@ window.__ModuleLoader__.load({
858
1607
  actions?.toggle(key);
859
1608
  setHover((h) => h === null ? h : { ...h });
860
1609
  };
1610
+ /**
1611
+ * C3: copy the hovered mark's FULL message text to the system clipboard.
1612
+ * The acknowledgement only shows when the write actually succeeded.
1613
+ */
1614
+ const onCopy = async (mark) => {
1615
+ if (await copyText(mark.text)) setCopiedKey(mark.key);
1616
+ };
1617
+ /**
1618
+ * C3: fork the session at the hovered mark, anchoring the cut at its event
1619
+ * seq. The acknowledgement only shows once the fork resolved.
1620
+ */
1621
+ const onFork = (mark) => {
1622
+ forkAt(mark.seq).then(() => setForkedKey(mark.key));
1623
+ };
861
1624
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
862
1625
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
863
1626
  style: {
@@ -869,17 +1632,21 @@ window.__ModuleLoader__.load({
869
1632
  pointerEvents: "auto",
870
1633
  zIndex: 100,
871
1634
  display: "flex",
872
- flexDirection: "column"
1635
+ flexDirection: "column",
1636
+ gap: 6,
1637
+ paddingTop: 6
873
1638
  },
874
- "aria-label": "会话里程碑",
1639
+ "aria-label": t("rail.label"),
1640
+ "data-focus-active": focusActive ? "true" : void 0,
875
1641
  children: [
876
1642
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
1643
+ focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
877
1644
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
878
1645
  type: "button",
879
1646
  "data-load-older": true,
880
1647
  "data-loading-older": loadingOlder ? "true" : void 0,
881
- title: "加载更早消息",
882
- "aria-label": "加载更早消息",
1648
+ title: t("load.older"),
1649
+ "aria-label": t("load.older"),
883
1650
  disabled: loadingOlder,
884
1651
  onClick: () => {
885
1652
  loadOlder();
@@ -896,7 +1663,7 @@ window.__ModuleLoader__.load({
896
1663
  padding: 0,
897
1664
  cursor: loadingOlder ? "default" : "pointer",
898
1665
  color: loadingOlder ? "#5a6375" : "#8b96ab",
899
- fontSize: 11,
1666
+ fontSize: 13,
900
1667
  lineHeight: 1,
901
1668
  letterSpacing: 1
902
1669
  },
@@ -905,7 +1672,7 @@ window.__ModuleLoader__.load({
905
1672
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
906
1673
  type: "button",
907
1674
  "data-bookmarks-toggle": true,
908
- "aria-label": "只看收藏",
1675
+ "aria-label": t("bookmark.filter"),
909
1676
  "aria-pressed": bookmarksOnly,
910
1677
  "data-active": bookmarksOnly ? "true" : void 0,
911
1678
  onClick: () => setBookmarksOnly((v) => !v),
@@ -923,8 +1690,8 @@ window.__ModuleLoader__.load({
923
1690
  color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
924
1691
  },
925
1692
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
926
- width: "13",
927
- height: "13",
1693
+ width: "16",
1694
+ height: "16",
928
1695
  viewBox: "0 0 24 24",
929
1696
  fill: bookmarksOnly ? "currentColor" : "none",
930
1697
  stroke: "currentColor",
@@ -934,6 +1701,122 @@ window.__ModuleLoader__.load({
934
1701
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
935
1702
  })
936
1703
  }),
1704
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1705
+ type: "button",
1706
+ "data-focus-toggle": true,
1707
+ "aria-label": focusActive ? t("focus.off") : t("focus.on"),
1708
+ title: focusActive ? t("focus.off") : t("focus.on"),
1709
+ "aria-pressed": focusActive,
1710
+ onClick: () => setFocusActive((v) => !v),
1711
+ style: {
1712
+ width: DOT_HIT,
1713
+ height: DOT_HIT,
1714
+ flexShrink: 0,
1715
+ display: "flex",
1716
+ alignItems: "center",
1717
+ justifyContent: "center",
1718
+ background: focusActive ? "rgba(126, 226, 168, 0.14)" : "transparent",
1719
+ border: "none",
1720
+ padding: 0,
1721
+ cursor: "pointer",
1722
+ color: focusActive ? "#7ee2a8" : "#8b96ab"
1723
+ },
1724
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1725
+ width: "16",
1726
+ height: "16",
1727
+ viewBox: "0 0 24 24",
1728
+ fill: "none",
1729
+ stroke: "currentColor",
1730
+ strokeWidth: "2",
1731
+ strokeLinecap: "round",
1732
+ strokeLinejoin: "round",
1733
+ "aria-hidden": "true",
1734
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1735
+ cx: "12",
1736
+ cy: "12",
1737
+ r: "3"
1738
+ })]
1739
+ })
1740
+ }),
1741
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1742
+ type: "button",
1743
+ "data-list-toggle": true,
1744
+ "aria-label": listOpen ? t("list.close") : t("list.open"),
1745
+ title: listOpen ? t("list.close") : t("list.open"),
1746
+ "aria-pressed": listOpen,
1747
+ onClick: () => setListOpen((v) => !v),
1748
+ style: {
1749
+ width: DOT_HIT,
1750
+ height: DOT_HIT,
1751
+ flexShrink: 0,
1752
+ display: "flex",
1753
+ alignItems: "center",
1754
+ justifyContent: "center",
1755
+ background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1756
+ border: "none",
1757
+ padding: 0,
1758
+ cursor: "pointer",
1759
+ color: listOpen ? "#9db8ff" : "#8b96ab"
1760
+ },
1761
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1762
+ width: "16",
1763
+ height: "16",
1764
+ viewBox: "0 0 24 24",
1765
+ fill: "none",
1766
+ stroke: "currentColor",
1767
+ strokeWidth: "2.5",
1768
+ strokeLinecap: "round",
1769
+ "aria-hidden": "true",
1770
+ children: [
1771
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
1772
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
1773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
1774
+ ]
1775
+ })
1776
+ }),
1777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1778
+ type: "button",
1779
+ "data-session-search-toggle": true,
1780
+ "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
1781
+ title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
1782
+ "aria-pressed": crossOpen,
1783
+ onClick: () => setCrossOpen((v) => !v),
1784
+ style: {
1785
+ width: DOT_HIT,
1786
+ height: DOT_HIT,
1787
+ flexShrink: 0,
1788
+ display: "flex",
1789
+ alignItems: "center",
1790
+ justifyContent: "center",
1791
+ background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1792
+ border: "none",
1793
+ padding: 0,
1794
+ cursor: "pointer",
1795
+ color: crossOpen ? "#9db8ff" : "#8b96ab"
1796
+ },
1797
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1798
+ width: "16",
1799
+ height: "16",
1800
+ viewBox: "0 0 24 24",
1801
+ fill: "none",
1802
+ stroke: "currentColor",
1803
+ strokeWidth: "2",
1804
+ strokeLinecap: "round",
1805
+ strokeLinejoin: "round",
1806
+ "aria-hidden": "true",
1807
+ children: [
1808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
1809
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
1810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
1811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1812
+ cx: "17",
1813
+ cy: "7",
1814
+ r: "3.5"
1815
+ }),
1816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
1817
+ ]
1818
+ })
1819
+ }),
937
1820
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
938
1821
  panelTop: railBox.top,
939
1822
  panelRight: railBox.right + DOT_HIT + 8,
@@ -947,13 +1830,29 @@ window.__ModuleLoader__.load({
947
1830
  })),
948
1831
  onQueryChange: updateQuery,
949
1832
  onSearchKeyDown,
950
- onClear: clearSearch
1833
+ onClear: clearSearch,
1834
+ t
1835
+ }),
1836
+ listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
1837
+ panelTop: railBox.top,
1838
+ panelRight: railBox.right + DOT_HIT + 8,
1839
+ marks,
1840
+ onJump: jump,
1841
+ t
1842
+ }),
1843
+ crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
1844
+ panelTop: railBox.top,
1845
+ panelRight: railBox.right + DOT_HIT + 8,
1846
+ onClose: () => setCrossOpen(false),
1847
+ searchSessions,
1848
+ openSession,
1849
+ t
951
1850
  }),
952
1851
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
953
1852
  ref: listRef,
954
1853
  "data-rail-list": true,
955
1854
  tabIndex: 0,
956
- "aria-label": "会话里程碑列表",
1855
+ "aria-label": t("rail.list"),
957
1856
  onFocus: onListFocus,
958
1857
  onKeyDown: onListKeyDown,
959
1858
  style: {
@@ -967,25 +1866,38 @@ window.__ModuleLoader__.load({
967
1866
  padding: "6px 0",
968
1867
  scrollbarWidth: "none"
969
1868
  },
970
- children: displayMarks.map((mark, i) => {
1869
+ children: render.items.map((item, i) => {
1870
+ const showSeparator = separatorIndices.has(i);
1871
+ const mark = displayMarks[item.displayIndex];
1872
+ const summaryCount = collapsedSummaries.get(mark.key);
971
1873
  const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
972
1874
  const dotState = markState({
973
1875
  key: mark.key,
974
1876
  hasQuery,
975
- isMatch: matches.includes(i),
976
- isActive: i === activeMarkIndex,
1877
+ isMatch: matches.includes(item.displayIndex),
1878
+ isActive: item.displayIndex === activeMarkIndex,
977
1879
  isCurrent: !hasQuery && mark.key === currentKey
978
1880
  });
979
1881
  const isHovered = hover?.mark.key === mark.key;
980
1882
  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
1883
  const badge = deriveBadge({
982
1884
  nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
983
- lastMark: i === displayMarks.length - 1,
1885
+ lastMark: item.displayIndex === displayMarks.length - 1,
984
1886
  running,
985
1887
  awaitingInput
986
1888
  });
987
1889
  const ringStyle = badge === null ? null : badgeRingStyle(badge);
988
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1890
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [showSeparator && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1891
+ "data-turn-separator": true,
1892
+ "data-turn": mark.turn === void 0 ? void 0 : mark.turn,
1893
+ style: {
1894
+ width: DOT_HIT - 8,
1895
+ height: 1,
1896
+ flexShrink: 0,
1897
+ background: "rgba(139, 150, 171, 0.35)",
1898
+ borderRadius: 1
1899
+ }
1900
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
989
1901
  type: "button",
990
1902
  style: {
991
1903
  width: DOT_HIT,
@@ -1002,15 +1914,17 @@ window.__ModuleLoader__.load({
1002
1914
  onMouseEnter: (e) => {
1003
1915
  const rect = e.currentTarget.getBoundingClientRect();
1004
1916
  setHover({
1005
- ...buildHover(mark, i),
1917
+ ...buildHover(mark, item.displayIndex),
1006
1918
  top: rect.top + rect.height / 2
1007
1919
  });
1008
1920
  },
1009
1921
  onClick: () => jump(mark.key),
1010
1922
  "data-rail-dot": true,
1923
+ "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
1924
+ "data-collapsed-count": summaryCount,
1011
1925
  tabIndex: focusIndex === i ? 0 : -1,
1012
1926
  onFocus: () => setFocusIndex(i),
1013
- "aria-label": `跳转到第 ${i + 1} 条消息`,
1927
+ "aria-label": t("jump.to", { n: item.displayIndex + 1 }),
1014
1928
  "aria-current": dotState === "active" ? "true" : void 0,
1015
1929
  "data-current": dotState === "current" ? "true" : void 0,
1016
1930
  "data-dimmed": dotState === "dimmed" ? "true" : void 0,
@@ -1020,7 +1934,7 @@ window.__ModuleLoader__.load({
1020
1934
  width: DOT_SIZE,
1021
1935
  height: DOT_SIZE,
1022
1936
  borderRadius: "50%",
1023
- background: dotColor(i, marks.length),
1937
+ background: dotColor(item.displayIndex, marks.length),
1024
1938
  boxShadow,
1025
1939
  transition: "transform 120ms ease, opacity 120ms ease",
1026
1940
  transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
@@ -1040,7 +1954,7 @@ window.__ModuleLoader__.load({
1040
1954
  }
1041
1955
  })
1042
1956
  })
1043
- }, mark.key);
1957
+ })] }, mark.key);
1044
1958
  })
1045
1959
  }),
1046
1960
  hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
@@ -1048,10 +1962,17 @@ window.__ModuleLoader__.load({
1048
1962
  hover,
1049
1963
  bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
1050
1964
  onToggleBookmark: () => onToggleBookmark(hover.mark.key),
1965
+ onCopy,
1966
+ onFork,
1967
+ copied: copiedKey === hover.mark.key,
1968
+ forked: forkedKey === hover.mark.key,
1969
+ turnCollapsed: hover.mark.turn !== void 0 && collapsedTurns.has(hover.mark.turn),
1970
+ onToggleCollapse,
1051
1971
  onMouseEnter: () => setHover((h) => h),
1052
- onMouseLeave: () => setHover(null)
1972
+ onMouseLeave: () => setHover(null),
1973
+ t
1053
1974
  }),
1054
- showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1975
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1055
1976
  "data-window-hint": true,
1056
1977
  style: {
1057
1978
  position: "absolute",
@@ -1059,17 +1980,13 @@ window.__ModuleLoader__.load({
1059
1980
  right: "100%",
1060
1981
  marginRight: 8,
1061
1982
  whiteSpace: "nowrap",
1062
- fontSize: 10,
1983
+ fontSize: 12,
1063
1984
  lineHeight: 1,
1064
1985
  color: "rgba(139, 150, 171, 0.9)",
1065
1986
  pointerEvents: "none",
1066
1987
  userSelect: "none"
1067
1988
  },
1068
- children: [
1069
- "已显示 ",
1070
- marks.length,
1071
- " 条 · 还有更早"
1072
- ]
1989
+ children: t("window.hint", { n: marks.length })
1073
1990
  })
1074
1991
  ]
1075
1992
  });
@@ -1107,6 +2024,46 @@ window.__ModuleLoader__.load({
1107
2024
  //#endregion
1108
2025
  //#region src/client/railInject.ts
1109
2026
  /**
2027
+ * Wrap the `session.search` RPC into a safe cross-session search action.
2028
+ *
2029
+ * - `ok: true` unwraps the value and joins each hit's human display title
2030
+ * from the session list snapshot (a session outside the list keeps no
2031
+ * title — the caller falls back to `search.untitled`).
2032
+ * - `ok: false` throws `new Error(error.message)` so the caller can surface
2033
+ * the business/transport error as the `search.error` state.
2034
+ * - A rejected RPC propagates unchanged.
2035
+ *
2036
+ * The list read happens inside the returned closure (never at factory time),
2037
+ * so the title join always reflects the current list snapshot.
2038
+ *
2039
+ * @param sessions - the injected sessions service (`ctx.sessions`).
2040
+ * @returns an action that searches all sessions' message content.
2041
+ */
2042
+ function createSessionSearch(sessions) {
2043
+ return async (query, signal) => {
2044
+ const result = await sessions.search(query, signal);
2045
+ if (!result.ok) throw new Error(result.error.message);
2046
+ const byId = sessions.list.getSnapshot().byId;
2047
+ return {
2048
+ items: result.value.items.map((item) => ({
2049
+ ...item,
2050
+ title: byId[item.sessionId]?.displayTitle
2051
+ })),
2052
+ hasMore: result.value.hasMore
2053
+ };
2054
+ };
2055
+ }
2056
+ /**
2057
+ * Wrap a session `open` call into a safe action that selects a listed session
2058
+ * as current — the exact selection path the sidebar uses on click.
2059
+ *
2060
+ * @param sessions - the injected sessions service (`ctx.sessions`).
2061
+ * @returns an action that opens the given session.
2062
+ */
2063
+ function createOpenSession(sessions) {
2064
+ return (id) => sessions.open(id);
2065
+ }
2066
+ /**
1110
2067
  * Wrap a session-bound `loadOlder` call into a safe action closure.
1111
2068
  *
1112
2069
  * - Missing binding: resolves (never throws on an unlisted/unscoped session).
@@ -1124,10 +2081,171 @@ window.__ModuleLoader__.load({
1124
2081
  await binding.session.loadOlder();
1125
2082
  };
1126
2083
  }
2084
+ /**
2085
+ * Wrap a session `fork` call into a safe action closure that anchors the cut
2086
+ * at an event seq and always bumps the inherited title.
2087
+ *
2088
+ * - Delegates to `sessions.fork({ sessionId, atSeq, increaseTitle: true })`;
2089
+ * the resolved child id is passed through.
2090
+ * - A rejection propagates unchanged so callers can surface the fork error.
2091
+ *
2092
+ * @param sessions - the injected sessions service (`ctx.sessions`).
2093
+ * @param sessionId - the session the rail is scoped to.
2094
+ * @returns an action that forks that session at a given event seq.
2095
+ */
2096
+ function createForkAt(sessions, sessionId) {
2097
+ return (atSeq) => sessions.fork({
2098
+ sessionId,
2099
+ atSeq,
2100
+ increaseTitle: true
2101
+ });
2102
+ }
2103
+ //#endregion
2104
+ //#region src/client/locales.ts
2105
+ /**
2106
+ * UI strings for the milestone rail, keyed flat (single-language-per-key,
2107
+ * no nesting) so the later i18n threading stays a mechanical
2108
+ * `value.replace('{name}', n)` substitution.
2109
+ *
2110
+ * `zh` is the source of truth and the key registry: it byte-matches the
2111
+ * current hardcoded output of MilestoneRail / MilestoneRailTooltip /
2112
+ * MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
2113
+ * the interpolated number or label), so swapping in these templates is
2114
+ * behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
2115
+ * missing English translation is a compile error, not a runtime miss.
2116
+ */
2117
+ const zh = {
2118
+ /** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
2119
+ "jump.to": "跳转到第 {n} 条消息",
2120
+ /** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
2121
+ "window.hint": "已显示 {n} 条 · 还有更早",
2122
+ /** Hover turn badge: `第 ${mark.turn} 轮`. */
2123
+ "turn.label": "第 {n} 轮",
2124
+ /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
2125
+ "pos.of": "第 {n} / {m} 条",
2126
+ /** Search input placeholder. */
2127
+ "search.placeholder": "搜索消息内容",
2128
+ /** aria-label on the search toggle button and the search input. */
2129
+ "search.label": "搜索消息",
2130
+ /** aria-label on the bookmarks-only filter toggle. */
2131
+ "bookmark.filter": "只看收藏",
2132
+ /** aria-label + title on the focus-mode toggle when focus is OFF (arm it). */
2133
+ "focus.on": "聚焦模式",
2134
+ /** aria-label + title on the focus-mode toggle when focus is ON (disarm it). */
2135
+ "focus.off": "退出聚焦",
2136
+ /** aria-label on the hover tooltip star toggle. */
2137
+ "bookmark.star": "收藏此消息",
2138
+ /** aria-label on the search clear button. */
2139
+ "search.clear": "清空搜索",
2140
+ /** title + aria-label on the load-older `···` button. */
2141
+ "load.older": "加载更早消息",
2142
+ /** aria-label on the rail root. */
2143
+ "rail.label": "会话里程碑",
2144
+ /** aria-label on the dot list. */
2145
+ "rail.list": "会话里程碑列表",
2146
+ /** Hover preview fallback for empty message text. */
2147
+ "no.text": "(无文本)",
2148
+ /** Relative time: `< 60s`. */
2149
+ "time.justNow": "刚刚",
2150
+ /** Relative time: `< 1h`. */
2151
+ "time.minutes": "{n} 分钟前",
2152
+ /** Relative time: `< 1d`. */
2153
+ "time.hours": "{n} 小时前",
2154
+ /** Relative time: `>= 1d`. */
2155
+ "time.days": "{n} 天前",
2156
+ /** Hover duration: `用时 {durationLabel}`. */
2157
+ "duration.label": "用时 {name}",
2158
+ /** Hover TTFT: `首字 {ttftLabel}`. */
2159
+ "ttft.label": "首字 {name}",
2160
+ /** TurnEndReason `completed`. */
2161
+ "reason.completed": "已完成",
2162
+ /** TurnEndReason `aborted`. */
2163
+ "reason.aborted": "已中止",
2164
+ /** TurnEndReason `error`. */
2165
+ "reason.error": "出错",
2166
+ /** TurnEndReason `max-tokens`. */
2167
+ "reason.maxTokens": "达到上限",
2168
+ /** TurnEndReason `interrupted`. */
2169
+ "reason.interrupted": "已中断",
2170
+ /** TurnEndReason `blocked`. */
2171
+ "reason.blocked": "已阻塞",
2172
+ /** Copy-message tooltip action. */
2173
+ "copy.message": "复制消息",
2174
+ /** Fork-from-here tooltip action. */
2175
+ "fork.here": "从此处 fork",
2176
+ /** Collapse-turn tooltip action. */
2177
+ "collapse.turn": "折叠此轮",
2178
+ /** Expand-turn tooltip action. */
2179
+ "expand.turn": "展开此轮",
2180
+ /** aria-label + title on the milestone-list toggle when the panel is CLOSED. */
2181
+ "list.open": "打开列表",
2182
+ /** aria-label + title on the milestone-list toggle when the panel is OPEN. */
2183
+ "list.close": "收起列表",
2184
+ /** Header title of the all-prompts list panel. */
2185
+ "list.label": "全部提问",
2186
+ /** Header title + input placeholder of the cross-session search panel. */
2187
+ "search.cross": "跨会话搜索",
2188
+ /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
2189
+ "search.cross.open": "打开跨会话搜索",
2190
+ /** aria-label + title on the cross-session search toggle when the panel is OPEN. */
2191
+ "search.cross.close": "收起跨会话搜索",
2192
+ /** Cross-session result row title fallback for sessions with no display title. */
2193
+ "search.untitled": "(无标题)",
2194
+ /** Cross-session search failure notice. */
2195
+ "search.error": "搜索失败,请重试",
2196
+ /** Cross-session search footer hint when the harness capped the result list. */
2197
+ "search.more": "结果已截断,请细化关键词"
2198
+ };
2199
+ const en = {
2200
+ "jump.to": "Jump to message {n}",
2201
+ "window.hint": "Showing {n} messages · more below",
2202
+ "turn.label": "Turn {n}",
2203
+ "pos.of": "Message {n} of {m}",
2204
+ "search.placeholder": "Search message content",
2205
+ "search.label": "Search messages",
2206
+ "bookmark.filter": "Bookmarks only",
2207
+ "focus.on": "Focus mode",
2208
+ "focus.off": "Exit focus",
2209
+ "bookmark.star": "Bookmark this message",
2210
+ "search.clear": "Clear search",
2211
+ "load.older": "Load older messages",
2212
+ "rail.label": "Session milestones",
2213
+ "rail.list": "Session milestone list",
2214
+ "no.text": "(no text)",
2215
+ "time.justNow": "Just now",
2216
+ "time.minutes": "{n} minutes ago",
2217
+ "time.hours": "{n} hours ago",
2218
+ "time.days": "{n} days ago",
2219
+ "duration.label": "Duration {name}",
2220
+ "ttft.label": "First token {name}",
2221
+ "reason.completed": "Completed",
2222
+ "reason.aborted": "Aborted",
2223
+ "reason.error": "Error",
2224
+ "reason.maxTokens": "Max tokens reached",
2225
+ "reason.interrupted": "Interrupted",
2226
+ "reason.blocked": "Blocked",
2227
+ "copy.message": "Copy message",
2228
+ "fork.here": "Fork from here",
2229
+ "collapse.turn": "Collapse turn",
2230
+ "expand.turn": "Expand turn",
2231
+ "list.open": "Open list",
2232
+ "list.close": "Close list",
2233
+ "list.label": "All prompts",
2234
+ "search.cross": "Cross-session search",
2235
+ "search.cross.open": "Open cross-session search",
2236
+ "search.cross.close": "Close cross-session search",
2237
+ "search.untitled": "(untitled)",
2238
+ "search.error": "Search failed, retry",
2239
+ "search.more": "Results truncated — refine your query"
2240
+ };
1127
2241
  //#endregion
1128
2242
  //#region src/client/index.ts
1129
2243
  /** Required services (cordis fiber inject). */
1130
- const inject = ["slots", "sessions"];
2244
+ const inject = [
2245
+ "slots",
2246
+ "sessions",
2247
+ "locale"
2248
+ ];
1131
2249
  /**
1132
2250
  * Register the overlay and rail once their slot declarations are on the
1133
2251
  * ledger. The overlay registers directly against the shipped shell.overlay
@@ -1136,6 +2254,10 @@ window.__ModuleLoader__.load({
1136
2254
  * @param ctx - client root context.
1137
2255
  */
1138
2256
  function apply(ctx) {
2257
+ ctx.effect(() => ctx.locale.register("dsh-milestone", {
2258
+ zh,
2259
+ en
2260
+ }), "dsh-milestone: dictionaries");
1139
2261
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1140
2262
  name: "shell.overlay",
1141
2263
  id: "milestone",
@@ -1148,7 +2270,13 @@ window.__ModuleLoader__.load({
1148
2270
  ctx.slots.inject("milestone.rail", () => ctx.slots.register({
1149
2271
  name: "milestone.rail",
1150
2272
  store: createBookmarksStore,
1151
- inject: (sessionId) => ({ loadOlder: createLoadOlder(ctx.sessions, sessionId) })
2273
+ locale: "dsh-milestone",
2274
+ inject: (sessionId) => ({
2275
+ loadOlder: createLoadOlder(ctx.sessions, sessionId),
2276
+ forkAt: createForkAt(ctx.sessions, sessionId),
2277
+ searchSessions: createSessionSearch(ctx.sessions),
2278
+ openSession: createOpenSession(ctx.sessions)
2279
+ })
1152
2280
  }, MilestoneRail));
1153
2281
  }
1154
2282
  //#endregion