dsh-milestone 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,8 +19,105 @@ window.__ModuleLoader__.load({
19
19
  });
20
20
  }
21
21
  //#endregion
22
+ //#region src/client/accent-utils.ts
23
+ /** True for a canonical 6-digit hex color (`#4d7cfd`, `#4D7CFD`). */
24
+ function isHexColor(value) {
25
+ return /^#[0-9a-fA-F]{6}$/.test(value);
26
+ }
27
+ /**
28
+ * Parse a hex color into channels.
29
+ * @param hex - `#rrggbb` (case-insensitive).
30
+ * @returns the RGB channels, or null when `hex` is not a canonical hex color.
31
+ */
32
+ function hexToRgb(hex) {
33
+ if (!isHexColor(hex)) return null;
34
+ const n = parseInt(hex.slice(1), 16);
35
+ return {
36
+ r: n >> 16 & 255,
37
+ g: n >> 8 & 255,
38
+ b: n & 255
39
+ };
40
+ }
41
+ /** Format an alpha to the short decimal form the rail's inline styles use (0.55, 0.2). */
42
+ function formatAlpha(alpha) {
43
+ return Math.round(Math.max(0, Math.min(1, alpha)) * 100) / 100;
44
+ }
45
+ /**
46
+ * Color with alpha as a CSS `rgba(r, g, b, a)` string; alpha clamps to [0, 1].
47
+ * @returns the rgba() string, or null when `hex` is invalid.
48
+ */
49
+ function rgbaString(hex, alpha) {
50
+ const rgb = hexToRgb(hex);
51
+ if (rgb === null) return null;
52
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${formatAlpha(alpha)})`;
53
+ }
54
+ /**
55
+ * Linear mix of two hex colors in RGB space.
56
+ * @param a - start color (t = 0).
57
+ * @param b - end color (t = 1).
58
+ * @param t - mix factor, clamped to [0, 1].
59
+ * @returns the mixed `#rrggbb`, or null when either input is invalid.
60
+ */
61
+ function mixHex(a, b, t) {
62
+ const ca = hexToRgb(a);
63
+ const cb = hexToRgb(b);
64
+ if (ca === null || cb === null) return null;
65
+ const k = Math.max(0, Math.min(1, t));
66
+ const channel = (from, to) => Math.round(from + (to - from) * k).toString(16).padStart(2, "0");
67
+ return `#${channel(ca.r, cb.r)}${channel(ca.g, cb.g)}${channel(ca.b, cb.b)}`;
68
+ }
69
+ /**
70
+ * Lighten a hex color toward white — the rail's "accent soft" text shade
71
+ * (t = 0 keeps the color, t = 1 is white).
72
+ */
73
+ function lighten(hex, t) {
74
+ return mixHex(hex, "#ffffff", t);
75
+ }
76
+ /**
77
+ * Convert a hex color to HSL. Double-checked against the standard RGB→HSL
78
+ * formulas; saturation/lightness are percentages, hue is degrees.
79
+ * @returns the HSL channels, or null when `hex` is invalid.
80
+ */
81
+ function hexToHsl(hex) {
82
+ const rgb = hexToRgb(hex);
83
+ if (rgb === null) return null;
84
+ const r = rgb.r / 255;
85
+ const g = rgb.g / 255;
86
+ const b = rgb.b / 255;
87
+ const max = Math.max(r, g, b);
88
+ const min = Math.min(r, g, b);
89
+ const delta = max - min;
90
+ let h = 0;
91
+ if (delta !== 0) if (max === r) h = 60 * ((g - b) / delta % 6);
92
+ else if (max === g) h = 60 * ((b - r) / delta + 2);
93
+ else h = 60 * ((r - g) / delta + 4);
94
+ if (h < 0) h += 360;
95
+ const l = (max + min) / 2;
96
+ const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
97
+ return {
98
+ h,
99
+ s: s * 100,
100
+ l: l * 100
101
+ };
102
+ }
103
+ //#endregion
22
104
  //#region src/client/badge-logic.ts
23
105
  /**
106
+ * Pure turn-health badge derivation + styling for the milestone rail: given
107
+ * the harness snapshot signals, decide which (if any) colored glow a mark's
108
+ * dot should wear, plus the style tokens for that badge.
109
+ *
110
+ * Rendering contract (M-design change): a badge is NO LONGER a hard 2px ring
111
+ * (border) — it is a concentric "soft glow" made of three layered box-shadows
112
+ * (a crisp inner ring at 55% alpha, then two blurred blooms), and the
113
+ * running/awaiting pulse breathes opacity + shadow intensity instead of
114
+ * expanding a ring. The `data-badge` value and the semantic color of every
115
+ * kind are unchanged.
116
+ *
117
+ * All functions are side-effect free (no React, no DOM) so the rail component
118
+ * can consume them directly and tests can exercise them in isolation.
119
+ */
120
+ /**
24
121
  * Derive the badge for one mark.
25
122
  *
26
123
  * Precedence: error > max-tokens > retry > running > awaiting. Node-derived
@@ -65,13 +162,41 @@ window.__ModuleLoader__.load({
65
162
  pulse: true
66
163
  }
67
164
  };
165
+ /** Build the layered soft-glow shadow for a badge color. */
166
+ function glowShadow(color) {
167
+ const layer = (alpha) => rgbaString(color, alpha) ?? "currentColor";
168
+ return [
169
+ `0 0 0 2px ${layer(.55)}`,
170
+ `0 0 8px 2px ${layer(.45)}`,
171
+ `0 0 16px 5px ${layer(.2)}`
172
+ ].join(", ");
173
+ }
68
174
  /**
69
175
  * Style tokens for a badge kind.
70
176
  * @param badge - the derived badge kind.
71
- * @returns the ring color and whether the dot should pulse.
177
+ * @returns the glow color, whether the dot should pulse (breathing glow), and
178
+ * the static multi-layer box-shadow string for the badge span.
72
179
  */
73
180
  function badgeRingStyle(badge) {
74
- return RING_STYLES[badge];
181
+ const base = RING_STYLES[badge];
182
+ return {
183
+ ...base,
184
+ shadow: glowShadow(base.color)
185
+ };
186
+ }
187
+ /**
188
+ * Breathing-glow keyframes for one badge color. The rail injects this (via an
189
+ * inline <style>) only while a pulsing badge is on screen — the color is baked
190
+ * into the alphas, so the animation needs no runtime var lookups. Both stops
191
+ * keep the SAME three-layer shape (only alpha/blur breathe), so the glow never
192
+ * looks like the old expanding ring.
193
+ */
194
+ function badgePulseCss(color) {
195
+ const layer = (alpha) => rgbaString(color, alpha) ?? "currentColor";
196
+ return `@keyframes milestone-badge-pulse {
197
+ 0%, 100% { opacity: 0.95; box-shadow: ${`0 0 0 2px ${layer(.55)}, 0 0 8px 2px ${layer(.45)}, 0 0 16px 5px ${layer(.2)}`}; }
198
+ 50% { opacity: 0.45; box-shadow: ${`0 0 0 2px ${layer(.35)}, 0 0 4px 1px ${layer(.25)}, 0 0 9px 3px ${layer(.12)}`}; }
199
+ }`;
75
200
  }
76
201
  //#endregion
77
202
  //#region src/client/bookmark-logic.ts
@@ -349,6 +474,13 @@ window.__ModuleLoader__.load({
349
474
  //#endregion
350
475
  //#region src/client/rail-logic.ts
351
476
  /**
477
+ * Pure rail logic for the milestone rail: full-text search matching, current-
478
+ * position highlight, match-cycle navigation, mark visual state, and dot color.
479
+ *
480
+ * All functions are side-effect free (no React, no DOM) so the rail component
481
+ * can consume them directly and tests can exercise them in isolation.
482
+ */
483
+ /**
352
484
  * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
353
485
  * `{ type: 'text', text: string }` block, joined with a single space and
354
486
  * trimmed. Unlike the rail's hover preview this is NOT truncated — callers use
@@ -427,14 +559,312 @@ window.__ModuleLoader__.load({
427
559
  if (opts.hasQuery) return "dimmed";
428
560
  return "normal";
429
561
  }
562
+ /** The default accent (the classic milestone blue) — dotColor's fallback. */
563
+ const DEFAULT_DOT_ACCENT = "#4d7cfd";
430
564
  /**
431
- * Blue gradient dot color, reproduced exactly from MilestoneRail: newest
432
- * (highest index) is deepest, oldest is lightest. 72% lightness fading to 45%.
565
+ * Accent-driven gradient dot color: hue/saturation come from the user's
566
+ * accent (settings 强调色), lightness walks 72% 45% (newest/highest index
567
+ * deepest, oldest lightest — the original gradient shape). Invalid accents
568
+ * degrade to the default blue.
433
569
  * @param index - dot position in the rail (0 = oldest).
434
570
  * @param total - number of dots.
571
+ * @param accent - the accent hex (`#rrggbb`); defaults to the classic blue.
572
+ */
573
+ function dotColor(index, total, accent = DEFAULT_DOT_ACCENT) {
574
+ const hsl = hexToHsl(accent) ?? hexToHsl("#4d7cfd");
575
+ const lightness = 72 - (total <= 1 ? 0 : index / (total - 1)) * 27;
576
+ return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${lightness}%)`;
577
+ }
578
+ //#endregion
579
+ //#region src/client/locales.ts
580
+ /**
581
+ * UI strings for the milestone rail, keyed flat (single-language-per-key,
582
+ * no nesting) so the later i18n threading stays a mechanical
583
+ * `value.replace('{name}', n)` substitution.
584
+ *
585
+ * `zh` is the source of truth and the key registry: it byte-matches the
586
+ * current hardcoded output of MilestoneRail / MilestoneRailTooltip /
587
+ * MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
588
+ * the interpolated number or label), so swapping in these templates is
589
+ * behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
590
+ * missing English translation is a compile error, not a runtime miss.
591
+ */
592
+ const zh = {
593
+ /** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
594
+ "jump.to": "跳转到第 {n} 条消息",
595
+ /** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
596
+ "window.hint": "已显示 {n} 条 · 还有更早",
597
+ /** Hover turn badge: `第 ${mark.turn} 轮`. */
598
+ "turn.label": "第 {n} 轮",
599
+ /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
600
+ "pos.of": "第 {n} / {m} 条",
601
+ /** Search input placeholder. */
602
+ "search.placeholder": "搜索消息内容",
603
+ /** aria-label on the search toggle button and the search input. */
604
+ "search.label": "搜索消息",
605
+ /** aria-label on the bookmarks-only filter toggle. */
606
+ "bookmark.filter": "只看收藏",
607
+ /** aria-label + title on the focus-mode toggle when focus is OFF (arm it). */
608
+ "focus.on": "聚焦模式",
609
+ /** aria-label + title on the focus-mode toggle when focus is ON (disarm it). */
610
+ "focus.off": "退出聚焦",
611
+ /** aria-label on the hover tooltip star toggle. */
612
+ "bookmark.star": "收藏此消息",
613
+ /** aria-label on the search clear button. */
614
+ "search.clear": "清空搜索",
615
+ /** title + aria-label on the load-older `···` button. */
616
+ "load.older": "加载更早消息",
617
+ /** aria-label on the rail root. */
618
+ "rail.label": "会话里程碑",
619
+ /** aria-label on the dot list. */
620
+ "rail.list": "会话里程碑列表",
621
+ /** Hover preview fallback for empty message text. */
622
+ "no.text": "(无文本)",
623
+ /** Relative time: `< 60s`. */
624
+ "time.justNow": "刚刚",
625
+ /** Relative time: `< 1h`. */
626
+ "time.minutes": "{n} 分钟前",
627
+ /** Relative time: `< 1d`. */
628
+ "time.hours": "{n} 小时前",
629
+ /** Relative time: `>= 1d`. */
630
+ "time.days": "{n} 天前",
631
+ /** Hover duration: `用时 {durationLabel}`. */
632
+ "duration.label": "用时 {name}",
633
+ /** Hover TTFT: `首字 {ttftLabel}`. */
634
+ "ttft.label": "首字 {name}",
635
+ /** TurnEndReason `completed`. */
636
+ "reason.completed": "已完成",
637
+ /** TurnEndReason `aborted`. */
638
+ "reason.aborted": "已中止",
639
+ /** TurnEndReason `error`. */
640
+ "reason.error": "出错",
641
+ /** TurnEndReason `max-tokens`. */
642
+ "reason.maxTokens": "达到上限",
643
+ /** TurnEndReason `interrupted`. */
644
+ "reason.interrupted": "已中断",
645
+ /** TurnEndReason `blocked`. */
646
+ "reason.blocked": "已阻塞",
647
+ /** Copy-message tooltip action. */
648
+ "copy.message": "复制消息",
649
+ /** Fork-from-here tooltip action. */
650
+ "fork.here": "从此处 fork",
651
+ /** Collapse-turn tooltip action. */
652
+ "collapse.turn": "折叠此轮",
653
+ /** Expand-turn tooltip action. */
654
+ "expand.turn": "展开此轮",
655
+ /** aria-label + title on the milestone-list toggle when the panel is CLOSED. */
656
+ "list.open": "打开列表",
657
+ /** aria-label + title on the milestone-list toggle when the panel is OPEN. */
658
+ "list.close": "收起列表",
659
+ /** Header title of the all-prompts list panel. */
660
+ "list.label": "全部提问",
661
+ /** Header title + input placeholder of the cross-session search panel. */
662
+ "search.cross": "跨会话搜索",
663
+ /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
664
+ "search.cross.open": "打开跨会话搜索",
665
+ /** aria-label + title on the cross-session search toggle when the panel is OPEN. */
666
+ "search.cross.close": "收起跨会话搜索",
667
+ /** Cross-session result row title fallback for sessions with no display title. */
668
+ "search.untitled": "(无标题)",
669
+ /** Cross-session search failure notice. */
670
+ "search.error": "搜索失败,请重试",
671
+ /** Cross-session search footer hint when the harness capped the result list. */
672
+ "search.more": "结果已截断,请细化关键词",
673
+ /** aria-label on the toolbar expand arrow while the toolbar is COLLAPSED (expand it). */
674
+ "toolbar.expand": "展开工具栏",
675
+ /** aria-label on the toolbar expand arrow while the toolbar is EXPANDED (collapse it). */
676
+ "toolbar.collapse": "收起工具栏",
677
+ /** aria-label on the toolbar settings gear while the settings menu is CLOSED. */
678
+ "toolbar.settings.open": "打开设置",
679
+ /** aria-label on the toolbar settings gear while the settings menu is OPEN. */
680
+ "toolbar.settings.close": "关闭设置",
681
+ /** Header title of the toolbar settings menu. */
682
+ "settings.title": "设置",
683
+ /** Per-feature toggle label inside the settings menu: keep visible while collapsed. */
684
+ "settings.pin": "在折叠外显示",
685
+ /** Settings action that clears every pinned feature. */
686
+ "settings.reset": "恢复默认",
687
+ /** Settings footer heading above the project links. */
688
+ "settings.support": "支持我们",
689
+ /** Settings footer link: the GitHub repository. */
690
+ "settings.repo": "GitHub 仓库",
691
+ /** Settings footer link: star the repository. */
692
+ "settings.star": "欢迎 Star ★",
693
+ /** Settings footer link: file an issue. */
694
+ "settings.issues": "提交 Issue",
695
+ /** Settings footer link: the npm install channel. */
696
+ "settings.npm": "npm 安装渠道",
697
+ /** Settings: feature-row name of the settings key itself (registry row). */
698
+ "settings.label": "设置",
699
+ /** aria-label on the settings modal close button. */
700
+ "settings.close": "关闭",
701
+ /** Settings modal: section heading for the feature pin rows. */
702
+ "settings.section.features": "功能与快捷区",
703
+ /** Settings modal: section heading for the personalization controls. */
704
+ "settings.section.personal": "个性化",
705
+ /** Settings: hover description — in-rail search. */
706
+ "settings.desc.search": "按完整消息内容过滤并跳转到对应消息",
707
+ /** Settings: hover description — all-prompts list. */
708
+ "settings.desc.list": "本会话全部提问一览",
709
+ /** Settings: hover description — cross-session search. */
710
+ "settings.desc.sessionSearch": "跨会话搜索所有会话",
711
+ /** Settings: hover description — bookmarks filter. */
712
+ "settings.desc.bookmarks": "只显示已收藏的消息",
713
+ /** Settings: hover description — focus mode. */
714
+ "settings.desc.focus": "淡化 AI 思考块,阅读更清爽",
715
+ /** Settings: hover description — update check. */
716
+ "settings.desc.updateCheck": "检查 npm 是否有新版本",
717
+ /** Settings: hover description — the settings key itself. */
718
+ "settings.desc.settings": "自定义工具栏与外观",
719
+ /** Settings personalization: accent color row label. */
720
+ "settings.accent": "强调色",
721
+ /** Settings personalization: custom color swatch label. */
722
+ "settings.custom": "自定义",
723
+ /** Settings personalization: icon/dot size slider label. */
724
+ "settings.iconSize": "图标 / 圆点大小",
725
+ /** Settings personalization: edge-distance slider label. */
726
+ "settings.inset": "距侧边距离",
727
+ /** Settings personalization: rail side row label. */
728
+ "settings.side": "位置",
729
+ /** Settings personalization: side radio — hug the left edge. */
730
+ "settings.side.left": "左侧",
731
+ /** Settings personalization: side radio — hug the right edge. */
732
+ "settings.side.right": "右侧",
733
+ /** B4 update-check: toolbar button label + title/aria-label. */
734
+ "update.check": "检查更新",
735
+ /** B4 update-check: popover title. */
736
+ "update.title": "更新检测",
737
+ /** B4 update-check: installed-version row label. */
738
+ "update.current": "当前版本",
739
+ /** B4 update-check: newest-published-version row label. */
740
+ "update.latest": "最新版本",
741
+ /** B4 update-check: conclusion when the installed version is current. */
742
+ "update.upToDate": "已是最新版本",
743
+ /** B4 update-check: conclusion when a newer version exists. */
744
+ "update.available": "发现新版本",
745
+ /** B4 update-check: link text for the npm upgrade channel. */
746
+ "update.goNpm": "去 npm 升级",
747
+ /** B4 update-check: supported-host-lines metadata row label. */
748
+ "update.hostLines": "已适配官方版本线",
749
+ /** B4 update-check: in-flight state of the manual check button. */
750
+ "update.checking": "检查中…",
751
+ /** B4 update-check: failed state heading. */
752
+ "update.failed": "检查失败",
753
+ /** B4 update-check: retry action inside the failed state. */
754
+ "update.retry": "重试",
755
+ /** Settings modal section title: language. */
756
+ "settings.language": "语言",
757
+ /** Language option: follow the harness UI language. */
758
+ "settings.lang.system": "跟随系统",
759
+ /** Language option: force Chinese copy. */
760
+ "settings.lang.zh": "中文",
761
+ /** Language option: force English copy. */
762
+ "settings.lang.en": "English"
763
+ };
764
+ const en = {
765
+ "jump.to": "Jump to message {n}",
766
+ "window.hint": "Showing {n} messages · more below",
767
+ "turn.label": "Turn {n}",
768
+ "pos.of": "Message {n} of {m}",
769
+ "search.placeholder": "Search message content",
770
+ "search.label": "Search messages",
771
+ "bookmark.filter": "Bookmarks only",
772
+ "focus.on": "Focus mode",
773
+ "focus.off": "Exit focus",
774
+ "bookmark.star": "Bookmark this message",
775
+ "search.clear": "Clear search",
776
+ "load.older": "Load older messages",
777
+ "rail.label": "Session milestones",
778
+ "rail.list": "Session milestone list",
779
+ "no.text": "(no text)",
780
+ "time.justNow": "Just now",
781
+ "time.minutes": "{n} minutes ago",
782
+ "time.hours": "{n} hours ago",
783
+ "time.days": "{n} days ago",
784
+ "duration.label": "Duration {name}",
785
+ "ttft.label": "First token {name}",
786
+ "reason.completed": "Completed",
787
+ "reason.aborted": "Aborted",
788
+ "reason.error": "Error",
789
+ "reason.maxTokens": "Max tokens reached",
790
+ "reason.interrupted": "Interrupted",
791
+ "reason.blocked": "Blocked",
792
+ "copy.message": "Copy message",
793
+ "fork.here": "Fork from here",
794
+ "collapse.turn": "Collapse turn",
795
+ "expand.turn": "Expand turn",
796
+ "list.open": "Open list",
797
+ "list.close": "Close list",
798
+ "list.label": "All prompts",
799
+ "search.cross": "Cross-session search",
800
+ "search.cross.open": "Open cross-session search",
801
+ "search.cross.close": "Close cross-session search",
802
+ "search.untitled": "(untitled)",
803
+ "search.error": "Search failed, retry",
804
+ "search.more": "Results truncated — refine your query",
805
+ "toolbar.expand": "Expand toolbar",
806
+ "toolbar.collapse": "Collapse toolbar",
807
+ "toolbar.settings.open": "Open settings",
808
+ "toolbar.settings.close": "Close settings",
809
+ "settings.title": "Settings",
810
+ "settings.pin": "Show outside collapse",
811
+ "settings.reset": "Restore defaults",
812
+ "settings.support": "Support us",
813
+ "settings.repo": "GitHub repo",
814
+ "settings.star": "Give us a Star ★",
815
+ "settings.issues": "Report an Issue",
816
+ "settings.npm": "Install via npm",
817
+ "settings.label": "Settings",
818
+ "settings.close": "Close",
819
+ "settings.section.features": "Features & Shortcuts",
820
+ "settings.section.personal": "Personalization",
821
+ "settings.desc.search": "Filter by full message text and jump to the match",
822
+ "settings.desc.list": "Overview of every prompt in this session",
823
+ "settings.desc.sessionSearch": "Search across all sessions",
824
+ "settings.desc.bookmarks": "Show bookmarked messages only",
825
+ "settings.desc.focus": "Dim AI thinking blocks for a cleaner read",
826
+ "settings.desc.updateCheck": "Check npm for a newer release",
827
+ "settings.desc.settings": "Customize the toolbar and appearance",
828
+ "settings.accent": "Accent color",
829
+ "settings.custom": "Custom",
830
+ "settings.iconSize": "Icon / dot size",
831
+ "settings.inset": "Distance from the edge",
832
+ "settings.side": "Position",
833
+ "settings.side.left": "Left",
834
+ "settings.side.right": "Right",
835
+ "update.check": "Check updates",
836
+ "update.title": "Update check",
837
+ "update.current": "Current version",
838
+ "update.latest": "Latest version",
839
+ "update.upToDate": "You are up to date",
840
+ "update.available": "Update available",
841
+ "update.goNpm": "Upgrade on npm",
842
+ "update.hostLines": "Supported official version lines",
843
+ "update.checking": "Checking…",
844
+ "update.failed": "Check failed",
845
+ "update.retry": "Retry",
846
+ "settings.language": "Language",
847
+ "settings.lang.system": "Follow system",
848
+ "settings.lang.zh": "Chinese",
849
+ "settings.lang.en": "English"
850
+ };
851
+ /**
852
+ * Interpolate `{name}` placeholders with params, matching the harness t seat's
853
+ * substitution shape; an unknown parameter leaves the placeholder verbatim.
854
+ */
855
+ function interpolate(template, params) {
856
+ if (params === void 0) return template;
857
+ return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (slot, name) => name in params ? String(params[name]) : slot);
858
+ }
859
+ /**
860
+ * Dictionary-backed translate for the forced-language override (locale prefs
861
+ * 'zh' / 'en'): resolves a key against the plugin's own dictionaries with
862
+ * placeholder interpolation; unknown keys pass through unchanged (same
863
+ * degradation as the harness seat).
435
864
  */
436
- function dotColor(index, total) {
437
- return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
865
+ function translateDict(dict, key, params) {
866
+ const template = dict[key];
867
+ return template === void 0 ? key : interpolate(template, params);
438
868
  }
439
869
  //#endregion
440
870
  //#region src/client/turn-group-logic.ts
@@ -503,13 +933,110 @@ window.__ModuleLoader__.load({
503
933
  };
504
934
  }
505
935
  //#endregion
936
+ //#region src/client/useOutsideDismiss.ts
937
+ /**
938
+ * useOutsideDismiss: the shared "click outside to dismiss" contract behind the
939
+ * rail's floating panels (in-rail search / all-prompts list / cross-session
940
+ * search). While a panel is open, a pointerdown anywhere OUTSIDE it calls the
941
+ * caller's close handler; a pointerdown inside the panel (or on an excluded
942
+ * element) is left untouched.
943
+ *
944
+ * Event choice — window `pointerdown`:
945
+ * - `pointerdown` unifies mouse / touch / pen, so dismissal works for every
946
+ * input the harness surfaces; a `mousedown`-only listener would miss
947
+ * touch taps entirely.
948
+ * - Closing on the DOWN side of the gesture feels instant — the panel is
949
+ * gone before the pointer is released, which is the expected behaviour
950
+ * for fixed floating layers.
951
+ *
952
+ * Lifecycle: the listener is attached ONLY while `open` and removed on close
953
+ * / unmount, so a closed panel never intercepts events and no listener leaks.
954
+ *
955
+ * Opening-gesture guard: hooks arm their listener in an effect, which React
956
+ * runs AFTER the toggle's pointerdown/click that opened the panel — that
957
+ * gesture can therefore never reach the listener by construction. As
958
+ * defense-in-depth the hook also records its arming time and drops any
959
+ * pointerdown whose `timeStamp` STRICTLY predates it (an event still in
960
+ * flight from the opening gesture). Same-tick and later events pass through,
961
+ * and synthetic test events with `timeStamp === 0` (which carry no real
962
+ * timestamp) are kept — the guard never swallows a legitimate dispatch.
963
+ *
964
+ * `options.exclude` names targets the caller keeps for its own handler — the
965
+ * panel's own toggle button is the canonical case: its click owns the
966
+ * open/close flip, so a pointerdown on it must NOT also dismiss through the
967
+ * hook, or clicking an armed toggle would close on pointerdown and re-open on
968
+ * click.
969
+ */
970
+ /**
971
+ * True when `target` is (or sits inside) an element matching `selector`.
972
+ * Panels use it to exclude their own rail-top toggle from dismissal (the
973
+ * toggle's data attribute is the established rail DOM contract, so the
974
+ * exclusion is just another access to it).
975
+ */
976
+ function outsideDismissMatches(target, selector) {
977
+ return target instanceof Element && target.closest(selector) !== null;
978
+ }
979
+ /**
980
+ * @param panelRef - the floating panel's root element (only mounted while open).
981
+ * @param open - whether the panel is open; the window listener arms only when true.
982
+ * @param onClose - called once per outside pointerdown. `undefined` keeps the
983
+ * hook inert — used while a call site still has no close path wired.
984
+ * @param options - optional exclusion predicate (see {@link OutsideDismissOptions}).
985
+ */
986
+ function useOutsideDismiss(panelRef, open, onClose, options) {
987
+ const onCloseRef = (0, react.useRef)(onClose);
988
+ const excludeRef = (0, react.useRef)(options?.exclude);
989
+ (0, react.useEffect)(() => {
990
+ onCloseRef.current = onClose;
991
+ excludeRef.current = options?.exclude;
992
+ });
993
+ (0, react.useEffect)(() => {
994
+ if (!open) return;
995
+ const armedAt = performance.now();
996
+ const onPointerDown = (e) => {
997
+ const onClose = onCloseRef.current;
998
+ if (onClose === void 0) return;
999
+ if (e.timeStamp > 0 && e.timeStamp < armedAt) return;
1000
+ if (excludeRef.current?.(e.target)) return;
1001
+ const panel = panelRef.current;
1002
+ if (panel !== null && e.target instanceof Node && panel.contains(e.target)) return;
1003
+ onClose();
1004
+ };
1005
+ window.addEventListener("pointerdown", onPointerDown);
1006
+ return () => window.removeEventListener("pointerdown", onPointerDown);
1007
+ }, [open, panelRef]);
1008
+ }
1009
+ //#endregion
506
1010
  //#region src/client/MilestoneRailSearch.tsx
1011
+ /**
1012
+ * RailSearchUi: the in-rail search chrome (F1) — the magnifier toggle pinned
1013
+ * to the rail's top and the compact search panel to its left (input, match
1014
+ * counter, clear button).
1015
+ *
1016
+ * Pure presentation: it owns no state. MilestoneRail holds the search state
1017
+ * and handlers and feeds them in as props, so the search lifecycle (query,
1018
+ * match cycle, escape semantics) stays component-local in the rail. Splitting
1019
+ * the chrome into its own file keeps the rail component under the size
1020
+ * ceiling while the two still render one DOM contract
1021
+ * (`data-search-toggle` / `data-rail-search` / `data-match-count` /
1022
+ * `data-search-clear`).
1023
+ *
1024
+ * Outside dismissal: while the panel is open, a pointerdown anywhere outside
1025
+ * it closes it (shared useOutsideDismiss hook). The toggle's own click keeps
1026
+ * its flip semantics — a pointerdown on `[data-search-toggle]` is excluded
1027
+ * from the hook and left to the rail's onToggle. Without a dedicated onClose
1028
+ * prop the fallback is the toggle-off path (query retained, same as clicking
1029
+ * the toggle), matching the rail's current call site; a dedicated onClose
1030
+ * (clearSearch-equivalent) takes precedence once MilestoneRail feeds one.
1031
+ */
507
1032
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
508
1033
  const DOT_HIT$1 = 28;
509
1034
  /**
510
1035
  * @param props - the search state slice plus the rail's event handlers.
511
1036
  */
512
- function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
1037
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, onClose, t }) {
1038
+ const panelRef = (0, react.useRef)(null);
1039
+ useOutsideDismiss(panelRef, panelOpen, onClose ?? (() => onToggle()), { exclude: (target) => outsideDismissMatches(target, "[data-search-toggle]") });
513
1040
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
514
1041
  type: "button",
515
1042
  "data-search-toggle": true,
@@ -545,6 +1072,7 @@ window.__ModuleLoader__.load({
545
1072
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21 21-4.3-4.3" })]
546
1073
  })
547
1074
  }), panelOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1075
+ ref: panelRef,
548
1076
  style: {
549
1077
  position: "fixed",
550
1078
  top: panelTop,
@@ -631,10 +1159,33 @@ window.__ModuleLoader__.load({
631
1159
  //#endregion
632
1160
  //#region src/client/MilestoneListPanel.tsx
633
1161
  /**
1162
+ * MilestoneListPanel: the expandable all-prompts panel (P3) — the compact
1163
+ * list chrome pinned to the rail's top that enumerates EVERY user-prompt
1164
+ * milestone (序号 + turn + preview), independent of the search/bookmarks
1165
+ * filters. Clicking an entry jumps to that message through the rail's own
1166
+ * `jump` handler (the same path the dots use).
1167
+ *
1168
+ * Pure presentation: it owns no state. MilestoneRail holds the `listOpen`
1169
+ * boolean (the toggle and the Escape/close semantics live there) and feeds
1170
+ * the panel its anchor, the full marks array, and the jump handler as props
1171
+ * — same split as RailSearchUi. Renders one DOM contract
1172
+ * (`data-milestone-list` root / `data-list-item` rows with `data-jump-key`).
1173
+ *
1174
+ * Outside dismissal: while the panel is mounted (it only renders while open),
1175
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
1176
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
1177
+ * through a `[data-list-toggle]` exclusion). MilestoneRail.tsx does NOT pass
1178
+ * onClose in the current tree (its owner is wiring it separately) — until
1179
+ * then the hook is inert and the panel keeps its existing behaviour.
1180
+ */
1181
+ /**
634
1182
  * @param props - the panel anchor, the full marks array, and the rail's jump handler.
635
1183
  */
636
- function MilestoneListPanel({ panelTop, panelRight, marks, onJump, t }) {
1184
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, onClose, t }) {
1185
+ const panelRef = (0, react.useRef)(null);
1186
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-list-toggle]") });
637
1187
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1188
+ ref: panelRef,
638
1189
  "data-milestone-list": true,
639
1190
  style: {
640
1191
  position: "fixed",
@@ -944,6 +1495,11 @@ window.__ModuleLoader__.load({
944
1495
  * (`data-session-search` root / `data-session-search-input` /
945
1496
  * `data-session-search-result` rows / `data-session-search-error` /
946
1497
  * `data-session-search-more`).
1498
+ *
1499
+ * Outside dismissal: while the panel is mounted (it only renders while open),
1500
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
1501
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
1502
+ * through a `[data-session-search-toggle]` exclusion).
947
1503
  */
948
1504
  /** Debounce window for the cross-session query (ms). */
949
1505
  const SEARCH_DEBOUNCE_MS = 250;
@@ -955,6 +1511,8 @@ window.__ModuleLoader__.load({
955
1511
  const [status, setStatus] = (0, react.useState)("idle");
956
1512
  const [hits, setHits] = (0, react.useState)([]);
957
1513
  const [hasMore, setHasMore] = (0, react.useState)(false);
1514
+ const panelRef = (0, react.useRef)(null);
1515
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-session-search-toggle]") });
958
1516
  (0, react.useEffect)(() => {
959
1517
  const trimmed = query.trim();
960
1518
  if (trimmed === "") {
@@ -983,6 +1541,7 @@ window.__ModuleLoader__.load({
983
1541
  if (e.key === "Escape") onClose();
984
1542
  };
985
1543
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1544
+ ref: panelRef,
986
1545
  "data-session-search": true,
987
1546
  style: {
988
1547
  position: "fixed",
@@ -1169,89 +1728,609 @@ window.__ModuleLoader__.load({
1169
1728
  return current;
1170
1729
  }
1171
1730
  //#endregion
1172
- //#region src/client/MilestoneRail.tsx
1731
+ //#region src/client/toolbar-prefs.ts
1173
1732
  /**
1174
- * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
1175
- * right-side vertical scrubber as a **fixed-pitch dot list** (like a git commit
1176
- * graph), NOT a minimap: one dot per user message, equal spacing regardless of
1177
- * conversation length. The list itself scrolls with the wheel when it outgrows
1178
- * the viewport; hovering a dot shows rich metadata (time, turn, duration, end
1179
- * reason, TTFT, tokens/sec) and clicking jumps the chat to that message.
1733
+ * toolbar-prefs: the persistence layer for the milestone rail's toolbar
1734
+ * personalization WHICH function keys stay visible outside the collapse
1735
+ * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
1736
+ * size, distance from the rail's screen edge, and rail side).
1180
1737
  *
1181
- * Data sources (all from the session-scoped `useSession` snapshot):
1182
- * - chat.order + chat.nodes.get(key) -> user-message nodes (key/id/location)
1183
- * - chat.timeline.turns.get(turn) -> turn start/end time, status, reason,
1184
- * and the ui-conversation 'turn-tail'
1185
- * location data (ttftMs/tokensPerSecond)
1738
+ * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
1739
+ * JSON object:
1186
1740
  *
1187
- * Positioning: the rail hugs the conversation scrollport's right edge (offset a
1188
- * little inward so it clears the native scrollbar and sits near the prose).
1741
+ * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
1742
+ * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en" }
1189
1743
  *
1190
- * In-rail search (F1): a magnifier toggle at the rail top opens a compact
1191
- * panel to the rail's left with a message-text search input; matches light up
1192
- * the dots (non-matches dim), Enter cycles the active match (wrapping) and
1193
- * jumps to it, Escape clears and closes. Matching runs over the FULL message
1194
- * text (`text` from rail-logic.extractText), not the truncated hover preview.
1744
+ * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
1745
+ * (and an entirely absent value) parses to the DEFAULT prefs with the new
1746
+ * fields at their defaults old users keep their pins untouched.
1195
1747
  *
1196
- * Current-position highlight (F2): the dot for the user message at/just above
1197
- * the conversation viewport top carries a white ring (`useCurrentAnchor`
1198
- * observes the scrollport, no polling).
1748
+ * All reads are sanitized per field:
1749
+ * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
1750
+ * first-seen (pin) order preserved;
1751
+ * - `accent`: a canonical `#rrggbb` hex, lowercased; anything else falls
1752
+ * back to the default blue;
1753
+ * - `iconSize` / `inset`: finite numbers snapped to the slider step
1754
+ * (even values) and clamped to the slider range;
1755
+ * - `side`: exactly `'left'` or `'right'`.
1199
1756
  *
1200
- * Load-older + window coverage (F3): when the session still has earlier pages
1201
- * (`hasMore`) a slim `···` button sits at the rail top and triggers the
1202
- * injected `loadOlder` action (disabled + `data-loading-older` while
1203
- * `loadingOlder`), and a compact hint to the rail's left states how many
1204
- * messages the current window covers.
1757
+ * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
1758
+ * dependency-free and unit-testable; MilestoneRail's feature registry keys
1759
+ * itself against the same `ToolbarPinId` type, so id drift is a compile error.
1205
1760
  */
1206
- /** Minimum user messages before the rail adds value. */
1207
- const MIN_MARKS = 2;
1208
- const PREVIEW_LENGTH = 80;
1209
- /** Stable no-bookmarks fallback for render paths without the store seat. */
1210
- const NO_BOOKMARKS = [];
1211
- /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
1212
- const NO_KINDS = [];
1761
+ /** The single localStorage key holding the toolbar preference blob. */
1762
+ const TOOLBAR_PREFS_KEY = "dsh-milestone.toolbar";
1213
1763
  /**
1214
- * Self-contained pulse keyframes for the transient badges (running/awaiting):
1215
- * an expanding currentColor ring on box-shadow plus an opacity beat, driven by
1216
- * `animation` on the badge ring span (kept in an inline <style> so the plugin
1217
- * stays zero-asset).
1764
+ * Canonical function-key ids that may be pinned outside the collapse, in
1765
+ * render order. `settings` is a REGULAR feature since the B-design move: the
1766
+ * gear left the always-visible chrome and now sits at the end of the expanded
1767
+ * feature queue (default unpinned). Adding a feature here (plus its registry
1768
+ * entry in MilestoneRail) is the whole "pin it" extension point.
1218
1769
  */
1219
- const BADGE_PULSE_CSS = `@keyframes milestone-badge-pulse {
1220
- 0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.85 }
1221
- 70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
1222
- 100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
1223
- }`;
1770
+ const TOOLBAR_PIN_IDS = [
1771
+ "search",
1772
+ "list",
1773
+ "sessionSearch",
1774
+ "bookmarks",
1775
+ "focus",
1776
+ "updateCheck",
1777
+ "settings"
1778
+ ];
1779
+ /** The canonical default prefs ("恢复默认" target; also the read fallback). */
1780
+ const DEFAULT_PREFS = {
1781
+ pinned: [],
1782
+ accent: "#4d7cfd",
1783
+ iconSize: 28,
1784
+ inset: 14,
1785
+ side: "right",
1786
+ locale: "system"
1787
+ };
1788
+ /** Type guard for registry ids — unknown strings never survive a parse. */
1789
+ function isToolbarPinId(id) {
1790
+ return TOOLBAR_PIN_IDS.includes(id);
1791
+ }
1792
+ /** Whitelist + dedupe + first-seen-order sanitizer for the pinned list. */
1793
+ function sanitizePinned(raw) {
1794
+ if (!Array.isArray(raw)) return [];
1795
+ const seen = /* @__PURE__ */ new Set();
1796
+ const result = [];
1797
+ for (const id of raw) {
1798
+ if (typeof id !== "string" || !isToolbarPinId(id) || seen.has(id)) continue;
1799
+ seen.add(id);
1800
+ result.push(id);
1801
+ }
1802
+ return result;
1803
+ }
1224
1804
  /**
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.
1805
+ * Snap a finite number to the nearest `step` inside [min, max]; any non-finite
1806
+ * or non-number input falls back to `fallback`. Used for both sliders so a
1807
+ * hand-edited blob (e.g. `iconSize: 21`) converges on a legal slider value.
1232
1808
  */
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; }`;
1235
- /** Visual dot diameter (px). */
1236
- const DOT_SIZE = 14;
1237
- /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
1238
- const DOT_HIT = 28;
1239
- /** Vertical gap between dot hit areas (px) — fixed pitch, never scaled. */
1240
- const DOT_GAP = 14;
1241
- /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
1242
- const RAIL_INSET = 14;
1809
+ function clampStep(value, min, max, step, fallback) {
1810
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1811
+ const snapped = Math.round(Math.min(max, Math.max(min, value)) / step) * step;
1812
+ return Math.min(max, Math.max(min, snapped));
1813
+ }
1243
1814
  /**
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.
1815
+ * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1816
+ * JSON, or a non-object shape all degrade to the DEFAULT prefs. Each field is
1817
+ * sanitized independently, so a half-corrupt blob keeps its valid parts
1818
+ * (e.g. an old `{pinned}`-only blob gains the default accent/size/inset/side).
1247
1819
  */
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;
1820
+ function parsePrefs(raw) {
1821
+ if (raw === null) return { ...DEFAULT_PREFS };
1822
+ let parsed;
1823
+ try {
1824
+ parsed = JSON.parse(raw);
1825
+ } catch {
1826
+ return { ...DEFAULT_PREFS };
1827
+ }
1828
+ if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
1829
+ const { pinned, accent, iconSize, inset, side, locale } = parsed;
1830
+ return {
1831
+ pinned: sanitizePinned(pinned),
1832
+ accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
1833
+ iconSize: clampStep(iconSize, 20, 36, 2, DEFAULT_PREFS.iconSize),
1834
+ inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
1835
+ side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
1836
+ locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale
1837
+ };
1838
+ }
1839
+ /**
1840
+ * Read + sanitize the persisted toolbar prefs from localStorage. Degrades to
1841
+ * the DEFAULT prefs when storage is unavailable (SSR, sandboxed iframe) —
1842
+ * personalization is a best-effort enhancement, never a render blocker.
1843
+ */
1844
+ function loadPrefs() {
1845
+ try {
1846
+ return parsePrefs(localStorage.getItem(TOOLBAR_PREFS_KEY));
1847
+ } catch {
1848
+ return { ...DEFAULT_PREFS };
1849
+ }
1850
+ }
1851
+ /**
1852
+ * Persist the full prefs (sanitized on the way out so a corrupt in-memory
1853
+ * value is never written). Swallows storage failures for the same best-effort
1854
+ * reason as {@link loadPrefs}.
1855
+ */
1856
+ function savePrefs(prefs) {
1857
+ const cleaned = parsePrefs(JSON.stringify(prefs));
1858
+ try {
1859
+ localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify(cleaned));
1860
+ } catch {}
1861
+ }
1862
+ /**
1863
+ * Pure toggle: adds `id` to the pinned set when absent, removes it when
1864
+ * present. Unknown ids are ignored (prefs returned unchanged) and the pinned
1865
+ * set is always deduped via the sanitizer, so callers can feed the result
1866
+ * straight back into {@link savePrefs}.
1867
+ */
1868
+ function togglePin(prefs, id) {
1869
+ if (!isToolbarPinId(id)) return { ...prefs };
1870
+ const next = new Set(prefs.pinned);
1871
+ if (next.has(id)) next.delete(id);
1872
+ else next.add(id);
1873
+ return {
1874
+ ...prefs,
1875
+ pinned: sanitizePinned([...next])
1876
+ };
1877
+ }
1878
+ //#endregion
1879
+ //#region src/client/version-logic.ts
1880
+ /**
1881
+ * Pure update-check logic for dsh-milestone, deliberately free of React, the
1882
+ * harness runtime, and any node-only API so it runs identically in the browser
1883
+ * bundle, vitest (jsdom), and future shells.
1884
+ *
1885
+ * Design intent:
1886
+ * - `compareVersions` / `needsUpdate` are pure, side-effect-free functions that
1887
+ * follow npm semver precedence for the shapes this plugin actually publishes
1888
+ * (`x.y.z` plus optional `-rc.N` / `-beta.N` prereleases). Anything else is
1889
+ * an explicit error rather than a silent mis-comparison.
1890
+ * - `fetchLatestVersion` is the only I/O: it reads the `latest` dist-tag from
1891
+ * npmmirror's cheap dist-tags endpoint first (it CORS-echoes this page's
1892
+ * Origin), then falls back to the full npm packument (ACAO: *). Every
1893
+ * request carries an internal 8s timeout combined with the caller's signal —
1894
+ * whichever aborts first wins — and every failure degrades to a structured
1895
+ * `{ ok: false }` result, never a throw.
1896
+ * - `SUPPORTED_HOST_LINES` is a SELF-DECLARED compatibility list, not a probe:
1897
+ * the harness reports no trustworthy host version in the browser
1898
+ * (`host.describe().version` is a stub), so the plugin declares which npm
1899
+ * `latest` host-version lines it supports and the UI renders that as-is.
1900
+ */
1901
+ /** Host-version lines this plugin declares support for (npm official latest
1902
+ * line; bump it as the peer/dependency ranges move). */
1903
+ const SUPPORTED_HOST_LINES = ["0.1.1-rc.2"];
1904
+ /** Internal per-request timeout for the network attempts. */
1905
+ const REQUEST_TIMEOUT_MS = 8e3;
1906
+ /** npmmirror dist-tags endpoint: lean JSON (`{"latest":"0.6.0", ...}`),
1907
+ * echoes this page's Origin in `Access-Control-Allow-Origin`. */
1908
+ const NPMIRROR_DIST_TAGS_URL = "https://registry.npmmirror.com/-/package/dsh-milestone/dist-tags";
1909
+ /** npm full packument; `dist-tags.latest` sits at the JSON root, and the
1910
+ * endpoint sends `Access-Control-Allow-Origin: *`. */
1911
+ const NPM_PACKUMENT_URL = "https://registry.npmjs.org/dsh-milestone";
1912
+ /** Numeric identifier test shared by core and prerelease segments. */
1913
+ const NUMERIC_RE = /^\d+$/;
1914
+ /**
1915
+ * Parse a version string into comparable parts.
1916
+ * Accepts `major[.minor[.patch]][-prerelease][+build]`; missing core segments
1917
+ * pad with 0 and `+build` metadata is ignored (per semver precedence).
1918
+ * @throws {Error} with the offending input when the shape is not parseable.
1919
+ */
1920
+ function parseVersion(input) {
1921
+ if (typeof input !== "string" || input.trim() === "") throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (expected "x.y.z" with optional "-pre" suffix)`);
1922
+ const s = input.trim();
1923
+ const plus = s.indexOf("+");
1924
+ const withoutBuild = plus === -1 ? s : s.slice(0, plus);
1925
+ const dash = withoutBuild.indexOf("-");
1926
+ const corePart = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
1927
+ const prePart = dash === -1 ? void 0 : withoutBuild.slice(dash + 1);
1928
+ const segments = corePart.split(".");
1929
+ if (segments.length < 1 || segments.length > 3 || !segments.every((seg) => NUMERIC_RE.test(seg))) throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (expected "x.y.z" with optional "-pre" suffix)`);
1930
+ const core = segments.map(Number);
1931
+ let pre = [];
1932
+ if (prePart !== void 0) {
1933
+ if (prePart === "" || !prePart.split(".").every((id) => /^[0-9A-Za-z-]+$/.test(id))) throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (bad prerelease suffix)`);
1934
+ pre = prePart.split(".");
1935
+ }
1936
+ return {
1937
+ core,
1938
+ pre
1939
+ };
1940
+ }
1941
+ /**
1942
+ * Compare two version strings per npm semver precedence.
1943
+ * @param a - first version (`x.y.z` with optional `-pre` suffix; build
1944
+ * metadata ignored; missing core segments pad with 0).
1945
+ * @param b - second version, same grammar.
1946
+ * @returns -1 when `a < b`, 0 when equal, 1 when `a > b`. Prereleases sort
1947
+ * below their same-number release; prerelease identifiers compare numerically
1948
+ * when both are numeric, lexically when both are alphanumeric, and numeric
1949
+ * identifiers always sort before alphanumeric ones.
1950
+ * @throws {Error} for inputs that do not match the grammar.
1951
+ */
1952
+ function compareVersions(a, b) {
1953
+ const pa = parseVersion(a);
1954
+ const pb = parseVersion(b);
1955
+ for (let i = 0; i < 3; i++) {
1956
+ const x = pa.core[i] ?? 0;
1957
+ const y = pb.core[i] ?? 0;
1958
+ if (x < y) return -1;
1959
+ if (x > y) return 1;
1960
+ }
1961
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
1962
+ if (pa.pre.length === 0) return 1;
1963
+ if (pb.pre.length === 0) return -1;
1964
+ const len = Math.max(pa.pre.length, pb.pre.length);
1965
+ for (let i = 0; i < len; i++) {
1966
+ const x = pa.pre[i];
1967
+ const y = pb.pre[i];
1968
+ if (x === void 0) return -1;
1969
+ if (y === void 0) return 1;
1970
+ const xNum = NUMERIC_RE.test(x);
1971
+ const yNum = NUMERIC_RE.test(y);
1972
+ if (xNum && yNum) {
1973
+ if (x !== y) return Number(x) < Number(y) ? -1 : 1;
1974
+ } else if (xNum) return -1;
1975
+ else if (yNum) return 1;
1976
+ else if (x !== y) return x < y ? -1 : 1;
1977
+ }
1978
+ return 0;
1979
+ }
1980
+ /**
1981
+ * Whether the installed plugin should offer an update.
1982
+ * @param current - installed version.
1983
+ * @param latest - newest published version.
1984
+ * @returns true only when `latest` is strictly greater than `current`
1985
+ * (identical versions, or a newer installed version, return false).
1986
+ * @throws {Error} when either input is not a parseable version.
1987
+ */
1988
+ function needsUpdate(current, latest) {
1989
+ return compareVersions(current, latest) < 0;
1990
+ }
1991
+ /**
1992
+ * Build a request signal: a fresh AbortController aborted by EITHER the
1993
+ * caller's external signal (which wins when it fires first) OR an internal
1994
+ * timeout. Uses `AbortSignal.timeout` when the environment provides it and
1995
+ * falls back to a manual `setTimeout` + abort otherwise (jsdom older
1996
+ * versions expose no `AbortSignal.timeout`).
1997
+ * @param external - caller signal; when already aborted the request fires
1998
+ * immediately with an aborted signal.
1999
+ * @param timeoutMs - internal timeout in ms.
2000
+ * @returns the combined signal plus a cleanup that detaches all listeners
2001
+ * (must be called so no listener outlives the request).
2002
+ */
2003
+ function createRequestSignal(external, timeoutMs) {
2004
+ const controller = new AbortController();
2005
+ const abort = () => controller.abort();
2006
+ const detach = [];
2007
+ if (external) if (external.aborted) controller.abort();
2008
+ else {
2009
+ external.addEventListener("abort", abort, { once: true });
2010
+ detach.push(() => external.removeEventListener("abort", abort));
2011
+ }
2012
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
2013
+ const t = AbortSignal.timeout(timeoutMs);
2014
+ t.addEventListener("abort", abort, { once: true });
2015
+ detach.push(() => t.removeEventListener("abort", abort));
2016
+ } else {
2017
+ const timer = setTimeout(abort, timeoutMs);
2018
+ detach.push(() => clearTimeout(timer));
2019
+ }
2020
+ return {
2021
+ signal: controller.signal,
2022
+ cleanup: () => {
2023
+ for (const fn of detach) fn();
2024
+ detach.length = 0;
2025
+ }
2026
+ };
2027
+ }
2028
+ /** Extract `latest` from the npmmirror dist-tags JSON root. */
2029
+ function extractFromDistTags(data) {
2030
+ if (data === null || typeof data !== "object") return null;
2031
+ const latest = data.latest;
2032
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
2033
+ }
2034
+ /** Extract `latest` from the npm packument's root `dist-tags` object. */
2035
+ function extractFromPackument(data) {
2036
+ if (data === null || typeof data !== "object") return null;
2037
+ const distTags = data["dist-tags"];
2038
+ if (distTags === null || typeof distTags !== "object") return null;
2039
+ const latest = distTags.latest;
2040
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
2041
+ }
2042
+ /** Human-readable failure detail for one endpoint attempt. */
2043
+ function describeError(source, cause) {
2044
+ const name = cause instanceof Error ? cause.name : "";
2045
+ const message = cause instanceof Error ? cause.message : String(cause);
2046
+ if (name === "AbortError") return `${source}: aborted (${message})`;
2047
+ return `${source}: ${message}`;
2048
+ }
2049
+ /**
2050
+ * One attempt at fetching the `latest` dist-tag from an endpoint.
2051
+ * @returns ok with the tag, or ok:false with a per-source error description.
2052
+ */
2053
+ async function tryFetchLatest(url, extract, source, external) {
2054
+ try {
2055
+ const { signal, cleanup } = createRequestSignal(external, REQUEST_TIMEOUT_MS);
2056
+ try {
2057
+ const res = await fetch(url, { signal });
2058
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2059
+ const latest = extract(await res.json());
2060
+ if (latest === null) throw new Error("unexpected response shape (no \"latest\" dist-tag)");
2061
+ return {
2062
+ ok: true,
2063
+ latest,
2064
+ source
2065
+ };
2066
+ } finally {
2067
+ cleanup();
2068
+ }
2069
+ } catch (cause) {
2070
+ return {
2071
+ ok: false,
2072
+ error: describeError(source, cause)
2073
+ };
2074
+ }
2075
+ }
2076
+ /**
2077
+ * Query npm for the newest published version of `dsh-milestone`.
2078
+ *
2079
+ * Strategy: npmmirror's dist-tags endpoint first (lightweight and CORS-open
2080
+ * to this Origin); if that fails or its shape is wrong, the full npm
2081
+ * packument fallback (`dist-tags.latest` at the JSON root). Each request
2082
+ * aborts after 8s via `AbortSignal.timeout` (manual timer fallback when the
2083
+ * environment lacks it) OR immediately when the passed-in signal aborts.
2084
+ * @param signal - optional caller abort signal; aborts the in-flight attempt
2085
+ * with priority over the internal timeout.
2086
+ * @returns the latest version and which registry answered, or a structured
2087
+ * error when both endpoints fail. Never throws.
2088
+ */
2089
+ async function fetchLatestVersion(signal) {
2090
+ const viaMirror = await tryFetchLatest(NPMIRROR_DIST_TAGS_URL, extractFromDistTags, "npmmirror", signal);
2091
+ if (viaMirror.ok) return viaMirror;
2092
+ const viaNpm = await tryFetchLatest(NPM_PACKUMENT_URL, extractFromPackument, "npm", signal);
2093
+ if (viaNpm.ok) return viaNpm;
2094
+ return {
2095
+ ok: false,
2096
+ error: `${viaMirror.error}; ${viaNpm.error}`
2097
+ };
2098
+ }
2099
+ /** localStorage key holding the last successful update check. */
2100
+ const UPDATE_CACHE_KEY = "dsh-milestone.update-cache";
2101
+ /**
2102
+ * Pure parse + freshness check of a stored blob: `null` for `null`/invalid
2103
+ * JSON, a wrong shape, or an entry older than {@link UPDATE_CACHE_TTL_MS}.
2104
+ * A `checkedAt` in the future (clock skew) is treated as fresh — it decays
2105
+ * naturally once wall time catches up. Never throws.
2106
+ * @param raw - the raw `localStorage` value (or null when absent).
2107
+ * @param now - wall-clock epoch ms to judge freshness against (injectable for
2108
+ * tests; defaults to `Date.now()`).
2109
+ */
2110
+ function parseUpdateCache(raw, now = Date.now()) {
2111
+ if (raw === null) return null;
2112
+ let parsed;
2113
+ try {
2114
+ parsed = JSON.parse(raw);
2115
+ } catch {
2116
+ return null;
2117
+ }
2118
+ if (typeof parsed !== "object" || parsed === null) return null;
2119
+ const { latest, source, checkedAt } = parsed;
2120
+ if (typeof latest !== "string" || latest === "") return null;
2121
+ if (source !== "npmmirror" && source !== "npm") return null;
2122
+ if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt)) return null;
2123
+ if (now - checkedAt >= 216e5) return null;
2124
+ return {
2125
+ latest,
2126
+ source,
2127
+ checkedAt
2128
+ };
2129
+ }
2130
+ /**
2131
+ * Read + parse the persisted cache entry from localStorage. Any storage
2132
+ * failure degrades to `null` (cache miss) — the update check is best-effort.
2133
+ * @param now - wall-clock epoch ms for freshness (see {@link parseUpdateCache}).
2134
+ */
2135
+ function readUpdateCache(now = Date.now()) {
2136
+ try {
2137
+ return parseUpdateCache(localStorage.getItem(UPDATE_CACHE_KEY), now);
2138
+ } catch {
2139
+ return null;
2140
+ }
2141
+ }
2142
+ /**
2143
+ * Persist a successful check result (the caller supplies `checkedAt`, usually
2144
+ * `Date.now()`). Silently ignores storage failures — the cache is an
2145
+ * optimization, never a hard dependency.
2146
+ */
2147
+ function writeUpdateCache(entry) {
2148
+ try {
2149
+ localStorage.setItem(UPDATE_CACHE_KEY, JSON.stringify(entry));
2150
+ } catch {}
2151
+ }
2152
+ /**
2153
+ * The cache-aware entry point for the UI: reuse an unexpired cached result
2154
+ * when one exists, otherwise query npm (npmmirror → packument fallback) and
2155
+ * persist a successful result for the next {@link UPDATE_CACHE_TTL_MS}.
2156
+ * Never throws; failures return the same structured `{ ok: false }` result as
2157
+ * {@link fetchLatestVersion}.
2158
+ * @param signal - optional caller abort signal, forwarded to the network
2159
+ * attempt only (a cache hit needs no signal).
2160
+ */
2161
+ async function loadCachedLatest(signal) {
2162
+ const cached = readUpdateCache();
2163
+ if (cached !== null) return {
2164
+ ok: true,
2165
+ latest: cached.latest,
2166
+ source: cached.source
2167
+ };
2168
+ const fresh = await fetchLatestVersion(signal);
2169
+ if (fresh.ok) writeUpdateCache({
2170
+ latest: fresh.latest,
2171
+ source: fresh.source,
2172
+ checkedAt: Date.now()
2173
+ });
2174
+ return fresh;
2175
+ }
2176
+ //#endregion
2177
+ //#region src/client/version-meta.ts
2178
+ /**
2179
+ * Installed plugin version. Injected at build time as
2180
+ * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
2181
+ */
2182
+ const PLUGIN_VERSION = "0.6.2";
2183
+ //#endregion
2184
+ //#region src/client/MilestoneRail.tsx
2185
+ /**
2186
+ * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
2187
+ * side vertical scrubber as a **fixed-pitch dot list** (like a git commit
2188
+ * graph), NOT a minimap: one dot per user message, equal spacing regardless of
2189
+ * conversation length. The list itself scrolls with the wheel when it outgrows
2190
+ * the viewport; hovering a dot shows rich metadata (time, turn, duration, end
2191
+ * reason, TTFT, tokens/sec) and clicking jumps the chat to that message.
2192
+ *
2193
+ * Data sources (all from the session-scoped `useSession` snapshot):
2194
+ * - chat.order + chat.nodes.get(key) -> user-message nodes (key/id/location)
2195
+ * - chat.timeline.turns.get(turn) -> turn start/end time, status, reason,
2196
+ * and the ui-conversation 'turn-tail'
2197
+ * location data (ttftMs/tokensPerSecond)
2198
+ *
2199
+ * Positioning: the rail hugs the conversation scrollport's chosen screen edge
2200
+ * (settings 位置: left or right), offset a little inward so it clears the
2201
+ * native scrollbar and sits near the prose.
2202
+ *
2203
+ * In-rail search (F1): a magnifier toggle at the rail top opens a compact
2204
+ * panel on the rail's free side with a message-text search input; matches
2205
+ * light up the dots (non-matches dim), Enter cycles the active match
2206
+ * (wrapping) and jumps to it, Escape clears and closes. Matching runs over the
2207
+ * FULL message text (`text` from rail-logic.extractText), not the truncated
2208
+ * hover preview.
2209
+ *
2210
+ * Current-position highlight (F2): the dot for the user message at/just above
2211
+ * the conversation viewport top carries a white ring (`useCurrentAnchor`
2212
+ * observes the scrollport, no polling).
2213
+ *
2214
+ * Load-older + window coverage (F3): when the session still has earlier pages
2215
+ * (`hasMore`) a slim `···` button sits at the rail top and triggers the
2216
+ * injected `loadOlder` action (disabled + `data-loading-older` while
2217
+ * `loadingOlder`), and a compact hint on the rail's free side states how many
2218
+ * messages the current window covers.
2219
+ *
2220
+ * Settings (B-design): the gear is a REGULAR toolbar feature ("settings",
2221
+ * registry last, default unpinned) — the collapsed rail shows only the expand
2222
+ * arrow plus the user's pinned keys, and expanding reveals the gear at the end
2223
+ * of the queue. The gear opens a CENTERED modal dialog (function-key pins /
2224
+ * hover descriptions, the personalization controls, and the support-us card
2225
+ * grid); everything the modal changes persists under `dsh-milestone.toolbar`.
2226
+ */
2227
+ /** Minimum user messages before the rail adds value. */
2228
+ const MIN_MARKS = 2;
2229
+ const PREVIEW_LENGTH = 80;
2230
+ /** Stable no-bookmarks fallback for render paths without the store seat. */
2231
+ const NO_BOOKMARKS = [];
2232
+ /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
2233
+ const NO_KINDS = [];
2234
+ /** Visual dot diameter at the default icon size (px). */
2235
+ const DOT_SIZE = 14;
2236
+ /** Hit area per dot at the default icon size (px) — larger than the dot. */
2237
+ const DOT_HIT = 28;
2238
+ /** Vertical gap between dot hit areas at the default icon size (px). */
2239
+ const DOT_GAP = 14;
2240
+ /**
2241
+ * Extra top margin a new turn group's FIRST dot gets (replaces the old
2242
+ * `data-turn-separator` line): same-group pitch stays DOT_GAP, a group
2243
+ * boundary opens another GROUP_GAP_EXTRA px (14 → 18 at default size),
2244
+ * expressed purely as spacing — no line element.
2245
+ */
2246
+ const GROUP_GAP_EXTRA = 4;
2247
+ /**
2248
+ * Preset accent swatches for the settings 强调色 row (default blue first).
2249
+ * The custom color input accepts any #rrggbb.
2250
+ */
2251
+ const ACCENT_PRESETS = [
2252
+ "#4d7cfd",
2253
+ "#22c55e",
2254
+ "#f59e0b",
2255
+ "#ef4444",
2256
+ "#a855f7",
2257
+ "#06b6d4",
2258
+ "#ec4899",
2259
+ "#f97316"
2260
+ ];
2261
+ /** Known floating-panel widths (px) used to anchor side=left panels to the
2262
+ * rail's free (right) side — the panel components take a viewport `right`
2263
+ * offset, so a left rail must back-calculate it from the panel width. */
2264
+ const PANEL_WIDTH_SEARCH = 220;
2265
+ const PANEL_WIDTH_STANDARD = 280;
2266
+ /** Tooltip anchor width: its maxWidth cap, so a tooltip never overlaps the rail. */
2267
+ const TOOLTIP_ANCHOR_WIDTH = 300;
2268
+ /**
2269
+ * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
2270
+ * conversation reads cleaner. The rule targets the stable, un-hashed
2271
+ * `data-variant="think"` attribute on the thinking-block ROOT (the harness
2272
+ * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
2273
+ * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
2274
+ * opening it, `[data-open]`) restores full opacity. Kept in an inline
2275
+ * <style> so the plugin stays zero-asset.
2276
+ */
2277
+ const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
2278
+ [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
2279
+ /**
2280
+ * Static settings-modal styling that needs `:hover` (which inline styles
2281
+ * cannot express): the support-us card grid (micro-lift + accent highlight)
2282
+ * and the pin-row hover wash. Accent values come from the rail root's CSS
2283
+ * variables (`--ms-accent`), so one block serves every accent. The
2284
+ * search-toggle recolor rule makes the EXTERNAL RailSearchUi chrome follow
2285
+ * the accent too (that file is owned by an earlier phase and cannot change);
2286
+ * `!important` is required because the toggle's own inline styles win
2287
+ * otherwise.
2288
+ */
2289
+ const MODAL_CSS = `
2290
+ [data-support-card] {
2291
+ display: flex; align-items: center; justify-content: center; gap: 8px;
2292
+ padding: 10px 12px; border-radius: 8px;
2293
+ background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12);
2294
+ color: #c7cede; text-decoration: none; font-size: 12.5px; line-height: 1.4;
2295
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
2296
+ }
2297
+ [data-support-card]:hover { transform: translateY(-2px); border-color: var(--ms-accent); background: rgba(255, 255, 255, 0.09); }
2298
+ [data-toolbar-pin-toggle]:hover, [data-toolbar-pin-toggle]:focus-visible { background: rgba(255, 255, 255, 0.06); }
2299
+ [data-search-toggle] { color: #8b96ab !important; }
2300
+ [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
2301
+ `;
2302
+ /**
2303
+ * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
2304
+ * deep-link attempt — the harness scrolls the conversation to the bottom on
2305
+ * load, so the deep link must land AFTER the view mounts.
2306
+ */
2307
+ const DEEP_LINK_INITIAL_DELAY = 100;
2308
+ /** P3: interval between DOM-row polls while waiting for the target to render. */
2309
+ const DEEP_LINK_POLL_DELAY = 150;
1251
2310
  /** P3: polls before falling back to a single `loadOlder` fetch. */
1252
2311
  const DEEP_LINK_MAX_POLLS = 5;
1253
2312
  /** P3: bounded polls after `loadOlder`, then the deep link gives up silently. */
1254
2313
  const DEEP_LINK_MAX_RETRY_POLLS = 5;
2314
+ /** B4 update-check: mount-time silent check delay (ms) — give the harness
2315
+ * time to settle before hitting the registry. */
2316
+ const UPDATE_CHECK_MOUNT_DELAY = 1500;
2317
+ /** Initial state: no check has completed yet, nothing to show. */
2318
+ const NO_UPDATE_CHECK = {
2319
+ phase: "idle",
2320
+ latest: null,
2321
+ source: null,
2322
+ error: null,
2323
+ available: false
2324
+ };
2325
+ /**
2326
+ * B4 display label for one supported host line: strips the fixed
2327
+ * `x.y.z` prefix and appends "line" — `0.1.1-rc.2` → `rc.2 line`,
2328
+ * `0.1.1` → `0.1.1 line`. Pure presentation metadata.
2329
+ */
2330
+ function hostLineLabel(line) {
2331
+ const suffix = line.replace(/^\d+\.\d+\.\d+-?/, "");
2332
+ return suffix === "" ? `${line} line` : `${suffix} line`;
2333
+ }
1255
2334
  /**
1256
2335
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
1257
2336
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
@@ -1287,7 +2366,7 @@ window.__ModuleLoader__.load({
1287
2366
  function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1288
2367
  items: [],
1289
2368
  hasMore: false
1290
- }), openSession = () => {}, t = (key) => key }) {
2369
+ }), openSession = () => {}, t: frameworkT = (key) => key }) {
1291
2370
  const order = useSession((s) => s.chat.order);
1292
2371
  const nodes = useSession((s) => s.chat.nodes);
1293
2372
  const locations = useSession((s) => s.chat.locations);
@@ -1392,43 +2471,141 @@ window.__ModuleLoader__.load({
1392
2471
  }
1393
2472
  return counts;
1394
2473
  }, [displayMarks]);
1395
- (0, react.useLayoutEffect)(() => {
1396
- if (marks.length < MIN_MARKS) {
1397
- setRailBox(null);
1398
- return;
1399
- }
1400
- const scrollport = document.querySelector("[data-conversation-scroll]");
1401
- if (scrollport === null) return;
1402
- const compute = () => {
1403
- const sp = scrollport.getBoundingClientRect();
1404
- setRailBox({
1405
- top: sp.top,
1406
- height: sp.height,
1407
- right: Math.max(8, window.innerWidth - sp.right + RAIL_INSET)
1408
- });
1409
- };
1410
- compute();
1411
- const observer = new ResizeObserver(compute);
1412
- observer.observe(scrollport);
1413
- window.addEventListener("resize", compute);
1414
- return () => {
1415
- observer.disconnect();
1416
- window.removeEventListener("resize", compute);
2474
+ const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
2475
+ const { pinned, accent, iconSize, inset, side } = prefs;
2476
+ const scale = iconSize / DOT_HIT;
2477
+ const hit = iconSize;
2478
+ const size = DOT_SIZE * scale;
2479
+ const gap = DOT_GAP * scale;
2480
+ const accentSoft = lighten(accent, .42) ?? "#9db8ff";
2481
+ const accentBg = rgbaString(accent, .18) ?? "rgba(77, 124, 254, 0.18)";
2482
+ const accentStrong = rgbaString(accent, .55) ?? "rgba(77, 124, 254, 0.55)";
2483
+ /**
2484
+ * Language override (settings → 语言): `system` delegates to the harness
2485
+ * `t` seat (the framework-synthesized interpreter for the registered
2486
+ * `dsh-milestone` namespace); `zh`/`en` force the plugin's own dictionaries
2487
+ * so the rail copy switches independently of the host UI language. Every
2488
+ * call site below — rail chrome, panels, tooltip and the settings modal —
2489
+ * already resolves through this binding, so the override is global to the
2490
+ * rail without threading a second translate prop anywhere.
2491
+ */
2492
+ const t = prefs.locale === "system" ? frameworkT : prefs.locale === "en" ? (key, params) => translateDict(en, key, params) : (key, params) => translateDict(zh, key, params);
2493
+ /** Write a patch of prefs through to state + localStorage. */
2494
+ const updatePrefs = (patch) => {
2495
+ setPrefs((prev) => {
2496
+ const next = {
2497
+ ...prev,
2498
+ ...patch
2499
+ };
2500
+ savePrefs(next);
2501
+ return next;
2502
+ });
2503
+ };
2504
+ /** B1: flip one feature's pin — state and the persisted blob update together. */
2505
+ const onTogglePin = (id) => {
2506
+ setPrefs((prev) => {
2507
+ const next = togglePin(prev, id);
2508
+ savePrefs(next);
2509
+ return next;
2510
+ });
2511
+ };
2512
+ /** B-design: 恢复默认 resets EVERYTHING — pins AND personalization. */
2513
+ const onResetAll = () => {
2514
+ const next = { ...DEFAULT_PREFS };
2515
+ setPrefs(next);
2516
+ savePrefs(next);
2517
+ };
2518
+ const [toolbarExpanded, setToolbarExpanded] = (0, react.useState)(false);
2519
+ const [expandHovered, setExpandHovered] = (0, react.useState)(false);
2520
+ const [settingsHovered, setSettingsHovered] = (0, react.useState)(false);
2521
+ const [settingsOpen, setSettingsOpen] = (0, react.useState)(false);
2522
+ /** The feature whose description the settings modal's right pane shows
2523
+ * (defaults to the first registry feature — 'search'). */
2524
+ const [descFeature, setDescFeature] = (0, react.useState)("search");
2525
+ const settingsRef = (0, react.useRef)(null);
2526
+ const settingsBtnRef = (0, react.useRef)(null);
2527
+ const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
2528
+ const [updateCheck, setUpdateCheck] = (0, react.useState)(NO_UPDATE_CHECK);
2529
+ const updatePanelRef = (0, react.useRef)(null);
2530
+ const updateBtnRef = (0, react.useRef)(null);
2531
+ /**
2532
+ * B1 settings modal: outside-pointerdown dismisses it (shared
2533
+ * useOutsideDismiss contract) with focus returning to the gear afterwards.
2534
+ * The modal's full-screen overlay wraps the dialog, so a pointerdown on the
2535
+ * backdrop (or anywhere outside the dialog) closes it; the gear's own click
2536
+ * keeps its flip semantics through a `[data-toolbar-settings]` exclusion —
2537
+ * pointerdown on an armed gear must not double-close.
2538
+ */
2539
+ useOutsideDismiss(settingsRef, settingsOpen, () => {
2540
+ setSettingsOpen(false);
2541
+ settingsBtnRef.current?.focus();
2542
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-toolbar-settings]") });
2543
+ (0, react.useEffect)(() => {
2544
+ if (!settingsOpen) return;
2545
+ const onKey = (e) => {
2546
+ if (e.key !== "Escape") return;
2547
+ setSettingsOpen(false);
2548
+ settingsBtnRef.current?.focus();
1417
2549
  };
1418
- }, [marks.length]);
1419
- (0, react.useLayoutEffect)(() => {
1420
- setFocusIndex((f) => clampIndex(f, render.items.length));
1421
- }, [render.items.length]);
2550
+ window.addEventListener("keydown", onKey);
2551
+ return () => window.removeEventListener("keydown", onKey);
2552
+ }, [settingsOpen]);
1422
2553
  (0, react.useEffect)(() => {
1423
- if (!listOpen && !crossOpen) return;
2554
+ if (!settingsOpen) return;
2555
+ (settingsRef.current?.querySelector("[data-toolbar-settings-close]"))?.focus();
2556
+ }, [settingsOpen]);
2557
+ /**
2558
+ * B4: run one update check. Cache-aware (`loadCachedLatest` reuses an
2559
+ * unexpired cached result without any network traffic) and never throws:
2560
+ * a network failure lands in the `failed` phase with the structured error,
2561
+ * and an unparseable `latest` (registry anomaly) is treated as "not
2562
+ * available" rather than crashing the panel.
2563
+ */
2564
+ const runUpdateCheck = () => {
2565
+ setUpdateCheck((prev) => ({
2566
+ ...prev,
2567
+ phase: "checking"
2568
+ }));
2569
+ loadCachedLatest().then((result) => {
2570
+ if (result.ok) {
2571
+ let available = false;
2572
+ try {
2573
+ available = needsUpdate(PLUGIN_VERSION, result.latest);
2574
+ } catch {
2575
+ available = false;
2576
+ }
2577
+ setUpdateCheck({
2578
+ phase: "ok",
2579
+ latest: result.latest,
2580
+ source: result.source,
2581
+ error: null,
2582
+ available
2583
+ });
2584
+ } else setUpdateCheck((prev) => ({
2585
+ ...prev,
2586
+ phase: "failed",
2587
+ error: result.error
2588
+ }));
2589
+ });
2590
+ };
2591
+ (0, react.useEffect)(() => {
2592
+ const timer = window.setTimeout(runUpdateCheck, UPDATE_CHECK_MOUNT_DELAY);
2593
+ return () => window.clearTimeout(timer);
2594
+ }, []);
2595
+ useOutsideDismiss(updatePanelRef, updateOpen, () => {
2596
+ setUpdateOpen(false);
2597
+ updateBtnRef.current?.focus();
2598
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-update-check]") });
2599
+ (0, react.useEffect)(() => {
2600
+ if (!updateOpen) return;
1424
2601
  const onKey = (e) => {
1425
2602
  if (e.key !== "Escape") return;
1426
- setListOpen(false);
1427
- setCrossOpen(false);
2603
+ setUpdateOpen(false);
2604
+ updateBtnRef.current?.focus();
1428
2605
  };
1429
2606
  window.addEventListener("keydown", onKey);
1430
2607
  return () => window.removeEventListener("keydown", onKey);
1431
- }, [listOpen, crossOpen]);
2608
+ }, [updateOpen]);
1432
2609
  const marksRef = (0, react.useRef)(marks);
1433
2610
  (0, react.useEffect)(() => {
1434
2611
  marksRef.current = marks;
@@ -1471,6 +2648,64 @@ window.__ModuleLoader__.load({
1471
2648
  window.addEventListener("hashchange", onHashChange);
1472
2649
  return () => window.removeEventListener("hashchange", onHashChange);
1473
2650
  }, []);
2651
+ (0, react.useLayoutEffect)(() => {
2652
+ if (marks.length < MIN_MARKS) {
2653
+ setRailBox(null);
2654
+ return;
2655
+ }
2656
+ const scrollport = document.querySelector("[data-conversation-scroll]");
2657
+ if (scrollport === null) return;
2658
+ const compute = () => {
2659
+ const sp = scrollport.getBoundingClientRect();
2660
+ setRailBox({
2661
+ top: sp.top,
2662
+ height: sp.height,
2663
+ right: Math.max(0, window.innerWidth - sp.right + inset),
2664
+ left: Math.max(0, sp.left + inset)
2665
+ });
2666
+ };
2667
+ compute();
2668
+ const observer = new ResizeObserver(compute);
2669
+ observer.observe(scrollport);
2670
+ window.addEventListener("resize", compute);
2671
+ return () => {
2672
+ observer.disconnect();
2673
+ window.removeEventListener("resize", compute);
2674
+ };
2675
+ }, [marks.length, inset]);
2676
+ (0, react.useLayoutEffect)(() => {
2677
+ setFocusIndex((f) => clampIndex(f, render.items.length));
2678
+ }, [render.items.length]);
2679
+ const lastBadge = (0, react.useMemo)(() => {
2680
+ if (displayMarks.length === 0) return null;
2681
+ const last = displayMarks[displayMarks.length - 1];
2682
+ return deriveBadge({
2683
+ nodeKinds: last.turn === void 0 ? NO_KINDS : kindsByTurn.get(last.turn) ?? NO_KINDS,
2684
+ lastMark: true,
2685
+ running,
2686
+ awaitingInput
2687
+ });
2688
+ }, [
2689
+ displayMarks,
2690
+ kindsByTurn,
2691
+ running,
2692
+ awaitingInput
2693
+ ]);
2694
+ const pulseCss = (0, react.useMemo)(() => {
2695
+ if (lastBadge === null) return null;
2696
+ const style = badgeRingStyle(lastBadge);
2697
+ return style.pulse ? badgePulseCss(style.color) : null;
2698
+ }, [lastBadge]);
2699
+ (0, react.useEffect)(() => {
2700
+ if (!listOpen && !crossOpen) return;
2701
+ const onKey = (e) => {
2702
+ if (e.key !== "Escape") return;
2703
+ setListOpen(false);
2704
+ setCrossOpen(false);
2705
+ };
2706
+ window.addEventListener("keydown", onKey);
2707
+ return () => window.removeEventListener("keydown", onKey);
2708
+ }, [listOpen, crossOpen]);
1474
2709
  if (railBox === null || marks.length < MIN_MARKS) return null;
1475
2710
  const updateQuery = (query) => {
1476
2711
  setSearch({
@@ -1507,6 +2742,279 @@ window.__ModuleLoader__.load({
1507
2742
  if (e.key === "Enter") advanceMatch();
1508
2743
  if (e.key === "Escape") closeSearch();
1509
2744
  };
2745
+ /**
2746
+ * B-design: viewport `right` offset for the floating layers. On the classic
2747
+ * right-side rail the panels sit left of the rail (their right edge at
2748
+ * railBox.right + hit + 8); on a LEFT rail every layer flips to the rail's
2749
+ * OTHER side — its left edge at railBox.left + hit + 8, which means its
2750
+ * viewport `right` must be backed out from the (known) panel width.
2751
+ */
2752
+ const panelRightFor = (panelWidth) => side === "left" ? window.innerWidth - (railBox.left + hit + 8 + panelWidth) : railBox.right + hit + 8;
2753
+ /** Close the settings modal via its backdrop/close button paths. */
2754
+ const closeSettings = () => {
2755
+ setSettingsOpen(false);
2756
+ settingsBtnRef.current?.focus();
2757
+ };
2758
+ /** B1: a feature renders while the toolbar is EXPANDED or while it is pinned. */
2759
+ const featureVisible = (id) => toolbarExpanded || pinned.includes(id);
2760
+ /** Base chrome-button style: accent-defined active tint, scaled hit area. */
2761
+ const chromeButtonStyle = (active) => ({
2762
+ width: hit,
2763
+ height: hit,
2764
+ flexShrink: 0,
2765
+ display: "flex",
2766
+ alignItems: "center",
2767
+ justifyContent: "center",
2768
+ background: active ? accentBg : "transparent",
2769
+ border: "none",
2770
+ padding: 0,
2771
+ cursor: "pointer",
2772
+ color: active ? accentSoft : "#8b96ab",
2773
+ transition: "background 120ms ease, color 120ms ease"
2774
+ });
2775
+ /**
2776
+ * B1: the data-driven feature registry. Each entry's render is the feature's
2777
+ * rail-top chrome (data attributes / aria semantics preserved), moved
2778
+ * verbatim from the previous static button block; `search` is the whole
2779
+ * RailSearchUi (toggle + panel) so its lifecycle stays component-local in
2780
+ * the rail (search state lives in the rail and survives unmount). `settings`
2781
+ * lives LAST in the queue — the gear is a regular, default-unpinned feature;
2782
+ * the modal must stay reachable via 展开→齿轮.
2783
+ * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式/检查更新/设置).
2784
+ *
2785
+ * EXTENSION POINT: push a new feature here (+ its id in toolbar-prefs.ts's
2786
+ * TOOLBAR_PIN_IDS and its locale keys) and pinning/settings/expand all
2787
+ * follow automatically — see the ToolbarFeatureDef doc above.
2788
+ */
2789
+ const toolbarFeatures = [
2790
+ {
2791
+ id: "search",
2792
+ labelKey: "search.label",
2793
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2794
+ panelTop: railBox.top,
2795
+ panelRight: panelRightFor(PANEL_WIDTH_SEARCH),
2796
+ query: search.query,
2797
+ panelOpen: search.panelOpen,
2798
+ matches: matches.length,
2799
+ total: displayMarks.length,
2800
+ onToggle: () => setSearch((s) => ({
2801
+ ...s,
2802
+ panelOpen: !s.panelOpen
2803
+ })),
2804
+ onQueryChange: updateQuery,
2805
+ onSearchKeyDown,
2806
+ onClear: clearSearch,
2807
+ t
2808
+ })
2809
+ },
2810
+ {
2811
+ id: "list",
2812
+ labelKey: "list.label",
2813
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2814
+ type: "button",
2815
+ "data-list-toggle": true,
2816
+ "aria-label": listOpen ? t("list.close") : t("list.open"),
2817
+ title: listOpen ? t("list.close") : t("list.open"),
2818
+ "aria-pressed": listOpen,
2819
+ onClick: () => setListOpen((v) => !v),
2820
+ style: chromeButtonStyle(listOpen),
2821
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2822
+ width: "16",
2823
+ height: "16",
2824
+ viewBox: "0 0 24 24",
2825
+ fill: "none",
2826
+ stroke: "currentColor",
2827
+ strokeWidth: "2.5",
2828
+ strokeLinecap: "round",
2829
+ "aria-hidden": "true",
2830
+ children: [
2831
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2832
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
2833
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
2834
+ ]
2835
+ })
2836
+ })
2837
+ },
2838
+ {
2839
+ id: "sessionSearch",
2840
+ labelKey: "search.cross",
2841
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2842
+ type: "button",
2843
+ "data-session-search-toggle": true,
2844
+ "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
2845
+ title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
2846
+ "aria-pressed": crossOpen,
2847
+ onClick: () => setCrossOpen((v) => !v),
2848
+ style: chromeButtonStyle(crossOpen),
2849
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2850
+ width: "16",
2851
+ height: "16",
2852
+ viewBox: "0 0 24 24",
2853
+ fill: "none",
2854
+ stroke: "currentColor",
2855
+ strokeWidth: "2",
2856
+ strokeLinecap: "round",
2857
+ strokeLinejoin: "round",
2858
+ "aria-hidden": "true",
2859
+ children: [
2860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
2861
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
2862
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
2863
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2864
+ cx: "17",
2865
+ cy: "7",
2866
+ r: "3.5"
2867
+ }),
2868
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
2869
+ ]
2870
+ })
2871
+ })
2872
+ },
2873
+ {
2874
+ id: "bookmarks",
2875
+ labelKey: "bookmark.filter",
2876
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2877
+ type: "button",
2878
+ "data-bookmarks-toggle": true,
2879
+ "aria-label": t("bookmark.filter"),
2880
+ "aria-pressed": bookmarksOnly,
2881
+ "data-active": bookmarksOnly ? "true" : void 0,
2882
+ onClick: () => setBookmarksOnly((v) => !v),
2883
+ style: chromeButtonStyle(bookmarksOnly),
2884
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2885
+ width: "16",
2886
+ height: "16",
2887
+ viewBox: "0 0 24 24",
2888
+ fill: bookmarksOnly ? "currentColor" : "none",
2889
+ stroke: "currentColor",
2890
+ strokeWidth: "2",
2891
+ strokeLinejoin: "round",
2892
+ "aria-hidden": "true",
2893
+ 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" })
2894
+ })
2895
+ })
2896
+ },
2897
+ {
2898
+ id: "focus",
2899
+ labelKey: "focus.on",
2900
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2901
+ type: "button",
2902
+ "data-focus-toggle": true,
2903
+ "aria-label": focusActive ? t("focus.off") : t("focus.on"),
2904
+ title: focusActive ? t("focus.off") : t("focus.on"),
2905
+ "aria-pressed": focusActive,
2906
+ onClick: () => setFocusActive((v) => !v),
2907
+ style: chromeButtonStyle(focusActive),
2908
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2909
+ width: "16",
2910
+ height: "16",
2911
+ viewBox: "0 0 24 24",
2912
+ fill: "none",
2913
+ stroke: "currentColor",
2914
+ strokeWidth: "2",
2915
+ strokeLinecap: "round",
2916
+ strokeLinejoin: "round",
2917
+ "aria-hidden": "true",
2918
+ 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", {
2919
+ cx: "12",
2920
+ cy: "12",
2921
+ r: "3"
2922
+ })]
2923
+ })
2924
+ })
2925
+ },
2926
+ {
2927
+ id: "updateCheck",
2928
+ labelKey: "update.check",
2929
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2930
+ type: "button",
2931
+ ref: updateBtnRef,
2932
+ "data-update-check": true,
2933
+ "aria-expanded": updateOpen,
2934
+ "aria-label": t("update.check"),
2935
+ title: t("update.check"),
2936
+ onClick: () => setUpdateOpen((v) => !v),
2937
+ style: {
2938
+ position: "relative",
2939
+ width: hit,
2940
+ height: hit,
2941
+ flexShrink: 0,
2942
+ display: "flex",
2943
+ alignItems: "center",
2944
+ justifyContent: "center",
2945
+ background: updateCheck.available ? "rgba(245, 197, 66, 0.14)" : updateOpen ? accentBg : "transparent",
2946
+ border: "none",
2947
+ padding: 0,
2948
+ cursor: "pointer",
2949
+ color: updateCheck.available ? "#f5c542" : updateOpen ? accentSoft : "#8b96ab"
2950
+ },
2951
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2952
+ width: "16",
2953
+ height: "16",
2954
+ viewBox: "0 0 24 24",
2955
+ fill: "none",
2956
+ stroke: "currentColor",
2957
+ strokeWidth: "2",
2958
+ strokeLinecap: "round",
2959
+ strokeLinejoin: "round",
2960
+ "aria-hidden": "true",
2961
+ children: [
2962
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }),
2963
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 3v5h-5" }),
2964
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }),
2965
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 16H3v5" })
2966
+ ]
2967
+ }), updateCheck.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2968
+ "data-update-available": true,
2969
+ style: {
2970
+ position: "absolute",
2971
+ top: -2,
2972
+ right: -2,
2973
+ width: 8,
2974
+ height: 8,
2975
+ borderRadius: "50%",
2976
+ background: "#f5c542",
2977
+ border: "2px solid rgba(20, 24, 32, 0.95)",
2978
+ pointerEvents: "none"
2979
+ }
2980
+ })]
2981
+ })
2982
+ },
2983
+ {
2984
+ id: "settings",
2985
+ labelKey: "settings.label",
2986
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2987
+ type: "button",
2988
+ ref: settingsBtnRef,
2989
+ "data-toolbar-settings": true,
2990
+ "aria-pressed": settingsOpen,
2991
+ "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2992
+ title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2993
+ onClick: () => setSettingsOpen((v) => !v),
2994
+ onMouseEnter: () => setSettingsHovered(true),
2995
+ onMouseLeave: () => setSettingsHovered(false),
2996
+ onFocus: () => setSettingsHovered(true),
2997
+ onBlur: () => setSettingsHovered(false),
2998
+ style: chromeButtonStyle(settingsOpen || settingsHovered),
2999
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3000
+ width: "16",
3001
+ height: "16",
3002
+ viewBox: "0 0 24 24",
3003
+ fill: "none",
3004
+ stroke: "currentColor",
3005
+ strokeWidth: "2",
3006
+ strokeLinecap: "round",
3007
+ strokeLinejoin: "round",
3008
+ "aria-hidden": "true",
3009
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3010
+ cx: "12",
3011
+ cy: "12",
3012
+ r: "3"
3013
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })]
3014
+ })
3015
+ })
3016
+ }
3017
+ ];
1510
3018
  /** Focus the dot at `index` (no-op while the list is unmounted). */
1511
3019
  const focusDotAt = (index) => {
1512
3020
  listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
@@ -1622,143 +3130,78 @@ window.__ModuleLoader__.load({
1622
3130
  forkAt(mark.seq).then(() => setForkedKey(mark.key));
1623
3131
  };
1624
3132
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
3133
+ const railStyle = {
3134
+ position: "fixed",
3135
+ top: railBox.top,
3136
+ ...side === "left" ? { left: railBox.left } : { right: railBox.right },
3137
+ height: railBox.height,
3138
+ width: hit,
3139
+ pointerEvents: "auto",
3140
+ zIndex: 100,
3141
+ display: "flex",
3142
+ flexDirection: "column",
3143
+ gap: 6,
3144
+ paddingTop: 6,
3145
+ "--ms-accent": accent,
3146
+ "--ms-accent-soft": accentSoft,
3147
+ "--ms-accent-bg": accentBg,
3148
+ "--ms-icon": `${iconSize}px`,
3149
+ "--ms-inset": `${inset}px`
3150
+ };
3151
+ const descKey = `settings.desc.${descFeature}`;
1625
3152
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1626
- style: {
1627
- position: "fixed",
1628
- top: railBox.top,
1629
- right: railBox.right,
1630
- height: railBox.height,
1631
- width: DOT_HIT,
1632
- pointerEvents: "auto",
1633
- zIndex: 100,
1634
- display: "flex",
1635
- flexDirection: "column",
1636
- gap: 6,
1637
- paddingTop: 6
1638
- },
3153
+ style: railStyle,
1639
3154
  "aria-label": t("rail.label"),
1640
3155
  "data-focus-active": focusActive ? "true" : void 0,
3156
+ "data-accent": accent,
3157
+ "data-side": side,
3158
+ "data-icon-size": String(iconSize),
3159
+ "data-inset": String(inset),
1641
3160
  children: [
1642
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
3161
+ pulseCss !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: pulseCss }),
1643
3162
  focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
3163
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: MODAL_CSS }),
1644
3164
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1645
- type: "button",
1646
- "data-load-older": true,
1647
- "data-loading-older": loadingOlder ? "true" : void 0,
1648
- title: t("load.older"),
1649
- "aria-label": t("load.older"),
1650
- disabled: loadingOlder,
1651
- onClick: () => {
1652
- loadOlder();
1653
- },
1654
- style: {
1655
- width: DOT_HIT,
1656
- height: DOT_HIT,
1657
- flexShrink: 0,
1658
- display: "flex",
1659
- alignItems: "center",
1660
- justifyContent: "center",
1661
- background: "transparent",
1662
- border: "none",
1663
- padding: 0,
1664
- cursor: loadingOlder ? "default" : "pointer",
1665
- color: loadingOlder ? "#5a6375" : "#8b96ab",
1666
- fontSize: 13,
1667
- lineHeight: 1,
1668
- letterSpacing: 1
1669
- },
1670
- children: "···"
1671
- }),
1672
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1673
- type: "button",
1674
- "data-bookmarks-toggle": true,
1675
- "aria-label": t("bookmark.filter"),
1676
- "aria-pressed": bookmarksOnly,
1677
- "data-active": bookmarksOnly ? "true" : void 0,
1678
- onClick: () => setBookmarksOnly((v) => !v),
1679
- style: {
1680
- width: DOT_HIT,
1681
- height: DOT_HIT,
1682
- flexShrink: 0,
1683
- display: "flex",
1684
- alignItems: "center",
1685
- justifyContent: "center",
1686
- background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
1687
- border: "none",
1688
- padding: 0,
1689
- cursor: "pointer",
1690
- color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
1691
- },
1692
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1693
- width: "16",
1694
- height: "16",
1695
- viewBox: "0 0 24 24",
1696
- fill: bookmarksOnly ? "currentColor" : "none",
1697
- stroke: "currentColor",
1698
- strokeWidth: "2",
1699
- strokeLinejoin: "round",
1700
- "aria-hidden": "true",
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" })
1702
- })
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),
3165
+ type: "button",
3166
+ "data-load-older": true,
3167
+ "data-loading-older": loadingOlder ? "true" : void 0,
3168
+ title: t("load.older"),
3169
+ "aria-label": t("load.older"),
3170
+ disabled: loadingOlder,
3171
+ onClick: () => {
3172
+ loadOlder();
3173
+ },
1748
3174
  style: {
1749
- width: DOT_HIT,
1750
- height: DOT_HIT,
3175
+ width: hit,
3176
+ height: hit,
1751
3177
  flexShrink: 0,
1752
3178
  display: "flex",
1753
3179
  alignItems: "center",
1754
3180
  justifyContent: "center",
1755
- background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
3181
+ background: "transparent",
1756
3182
  border: "none",
1757
3183
  padding: 0,
1758
- cursor: "pointer",
1759
- color: listOpen ? "#9db8ff" : "#8b96ab"
3184
+ cursor: loadingOlder ? "default" : "pointer",
3185
+ color: loadingOlder ? "#5a6375" : "#8b96ab",
3186
+ fontSize: 13,
3187
+ lineHeight: 1,
3188
+ letterSpacing: 1
1760
3189
  },
1761
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3190
+ children: "···"
3191
+ }),
3192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3193
+ type: "button",
3194
+ "data-toolbar-expand": true,
3195
+ "aria-expanded": toolbarExpanded,
3196
+ "aria-label": toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
3197
+ title: toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
3198
+ onClick: () => setToolbarExpanded((v) => !v),
3199
+ onMouseEnter: () => setExpandHovered(true),
3200
+ onMouseLeave: () => setExpandHovered(false),
3201
+ onFocus: () => setExpandHovered(true),
3202
+ onBlur: () => setExpandHovered(false),
3203
+ style: chromeButtonStyle(expandHovered),
3204
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1762
3205
  width: "16",
1763
3206
  height: "16",
1764
3207
  viewBox: "0 0 24 24",
@@ -1766,83 +3209,728 @@ window.__ModuleLoader__.load({
1766
3209
  stroke: "currentColor",
1767
3210
  strokeWidth: "2.5",
1768
3211
  strokeLinecap: "round",
3212
+ strokeLinejoin: "round",
1769
3213
  "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
- ]
3214
+ children: toolbarExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
1775
3215
  })
1776
3216
  }),
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),
3217
+ toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
3218
+ settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3219
+ "data-toolbar-settings-overlay": true,
3220
+ onClick: closeSettings,
1784
3221
  style: {
1785
- width: DOT_HIT,
1786
- height: DOT_HIT,
1787
- flexShrink: 0,
3222
+ position: "fixed",
3223
+ inset: 0,
3224
+ background: "rgba(8, 10, 15, 0.55)",
3225
+ zIndex: 105,
1788
3226
  display: "flex",
1789
3227
  alignItems: "center",
1790
3228
  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"
3229
+ padding: 16
1796
3230
  },
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",
3231
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3232
+ ref: settingsRef,
3233
+ "data-toolbar-settings-panel": true,
3234
+ role: "dialog",
3235
+ "aria-modal": "true",
3236
+ "aria-label": t("settings.title"),
3237
+ onClick: (e) => e.stopPropagation(),
3238
+ style: {
3239
+ width: "min(600px, 92vw)",
3240
+ maxHeight: "78vh",
3241
+ overflowY: "auto",
3242
+ padding: "16px 18px",
3243
+ background: "rgba(20, 24, 32, 0.98)",
3244
+ color: "#e6e8ee",
3245
+ borderRadius: 12,
3246
+ boxShadow: "0 20px 60px rgba(0, 0, 0, 0.5)"
3247
+ },
1807
3248
  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"
3249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3250
+ style: {
3251
+ display: "flex",
3252
+ alignItems: "center",
3253
+ justifyContent: "space-between",
3254
+ gap: 8,
3255
+ marginBottom: 12
3256
+ },
3257
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3258
+ "data-toolbar-settings-title": true,
3259
+ style: {
3260
+ fontSize: 15,
3261
+ fontWeight: 600,
3262
+ color: "#e6e8ee"
3263
+ },
3264
+ children: t("settings.title")
3265
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3266
+ type: "button",
3267
+ "data-toolbar-settings-close": true,
3268
+ "aria-label": t("settings.close"),
3269
+ title: t("settings.close"),
3270
+ onClick: closeSettings,
3271
+ style: {
3272
+ width: 26,
3273
+ height: 26,
3274
+ flexShrink: 0,
3275
+ display: "flex",
3276
+ alignItems: "center",
3277
+ justifyContent: "center",
3278
+ background: "transparent",
3279
+ border: "none",
3280
+ padding: 0,
3281
+ cursor: "pointer",
3282
+ color: "#8b96ab",
3283
+ borderRadius: 6,
3284
+ fontSize: 14,
3285
+ lineHeight: 1
3286
+ },
3287
+ children: "✕"
3288
+ })]
1815
3289
  }),
1816
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
3290
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3291
+ "data-settings-section": true,
3292
+ style: { marginBottom: 14 },
3293
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3294
+ "data-settings-section-title": true,
3295
+ style: {
3296
+ fontSize: 13,
3297
+ fontWeight: 600,
3298
+ color: "#c7cede",
3299
+ marginBottom: 8
3300
+ },
3301
+ children: t("settings.section.features")
3302
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3303
+ style: {
3304
+ display: "grid",
3305
+ gridTemplateColumns: "minmax(0, 1fr) 190px",
3306
+ gap: 12,
3307
+ alignItems: "stretch"
3308
+ },
3309
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3310
+ style: {
3311
+ display: "flex",
3312
+ flexDirection: "column",
3313
+ gap: 2
3314
+ },
3315
+ children: toolbarFeatures.map((feature) => {
3316
+ const checked = pinned.includes(feature.id);
3317
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3318
+ type: "button",
3319
+ role: "switch",
3320
+ "data-toolbar-pin-toggle": true,
3321
+ "data-pin-id": feature.id,
3322
+ "aria-checked": checked,
3323
+ "aria-label": t(feature.labelKey),
3324
+ onMouseEnter: () => setDescFeature(feature.id),
3325
+ onFocus: () => setDescFeature(feature.id),
3326
+ onClick: () => onTogglePin(feature.id),
3327
+ style: {
3328
+ display: "flex",
3329
+ alignItems: "center",
3330
+ justifyContent: "space-between",
3331
+ gap: 10,
3332
+ width: "100%",
3333
+ padding: "7px 10px",
3334
+ background: "transparent",
3335
+ border: "none",
3336
+ borderRadius: 8,
3337
+ cursor: "pointer",
3338
+ color: "#e6e8ee",
3339
+ fontSize: 13,
3340
+ textAlign: "left"
3341
+ },
3342
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3343
+ "aria-hidden": "true",
3344
+ style: {
3345
+ position: "relative",
3346
+ width: 32,
3347
+ height: 18,
3348
+ flexShrink: 0,
3349
+ borderRadius: 9,
3350
+ background: checked ? accentStrong : "rgba(255, 255, 255, 0.16)",
3351
+ transition: "background 120ms ease"
3352
+ },
3353
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
3354
+ position: "absolute",
3355
+ top: 2,
3356
+ left: checked ? 16 : 2,
3357
+ width: 14,
3358
+ height: 14,
3359
+ borderRadius: "50%",
3360
+ background: "#ffffff",
3361
+ transition: "left 120ms ease"
3362
+ } })
3363
+ })]
3364
+ }, feature.id);
3365
+ })
3366
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3367
+ "data-settings-desc": true,
3368
+ "aria-live": "polite",
3369
+ style: {
3370
+ display: "flex",
3371
+ alignItems: "center",
3372
+ padding: "10px 12px",
3373
+ borderRadius: 8,
3374
+ background: "rgba(255, 255, 255, 0.05)",
3375
+ color: "#b9c2d4",
3376
+ fontSize: 12.5,
3377
+ lineHeight: 1.5
3378
+ },
3379
+ children: t(descKey)
3380
+ })]
3381
+ })]
3382
+ }),
3383
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3384
+ "data-settings-section": true,
3385
+ "data-settings-personal": true,
3386
+ style: { marginBottom: 14 },
3387
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3388
+ "data-settings-section-title": true,
3389
+ style: {
3390
+ fontSize: 13,
3391
+ fontWeight: 600,
3392
+ color: "#c7cede",
3393
+ marginBottom: 8
3394
+ },
3395
+ children: t("settings.section.personal")
3396
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3397
+ style: {
3398
+ display: "flex",
3399
+ flexDirection: "column",
3400
+ gap: 10
3401
+ },
3402
+ children: [
3403
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3404
+ style: {
3405
+ display: "flex",
3406
+ alignItems: "center",
3407
+ gap: 10,
3408
+ flexWrap: "wrap"
3409
+ },
3410
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3411
+ style: {
3412
+ fontSize: 12.5,
3413
+ color: "#8b96ab",
3414
+ width: 90,
3415
+ flexShrink: 0
3416
+ },
3417
+ children: t("settings.accent")
3418
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3419
+ "data-accent-swatches": true,
3420
+ style: {
3421
+ display: "flex",
3422
+ alignItems: "center",
3423
+ gap: 6,
3424
+ flexWrap: "wrap"
3425
+ },
3426
+ children: [ACCENT_PRESETS.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3427
+ type: "button",
3428
+ "data-accent-swatch": true,
3429
+ "data-accent": preset,
3430
+ "aria-label": preset,
3431
+ "aria-pressed": accent === preset,
3432
+ onClick: () => updatePrefs({ accent: preset }),
3433
+ style: {
3434
+ width: 22,
3435
+ height: 22,
3436
+ borderRadius: "50%",
3437
+ background: preset,
3438
+ border: accent === preset ? "2px solid #ffffff" : "2px solid rgba(255, 255, 255, 0.25)",
3439
+ boxShadow: accent === preset ? `0 0 0 2px ${preset}` : "none",
3440
+ padding: 0,
3441
+ cursor: "pointer",
3442
+ transition: "border-color 120ms ease, box-shadow 120ms ease"
3443
+ }
3444
+ }, preset)), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3445
+ "data-accent-custom": true,
3446
+ style: {
3447
+ display: "inline-flex",
3448
+ alignItems: "center",
3449
+ gap: 6,
3450
+ fontSize: 12.5,
3451
+ color: "#b9c2d4",
3452
+ cursor: "pointer"
3453
+ },
3454
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3455
+ type: "color",
3456
+ value: accent,
3457
+ onChange: (e) => updatePrefs({ accent: e.target.value }),
3458
+ "aria-label": `${t("settings.custom")} ${t("settings.accent")}`,
3459
+ style: {
3460
+ width: 26,
3461
+ height: 26,
3462
+ padding: 0,
3463
+ border: "none",
3464
+ background: "transparent",
3465
+ cursor: "pointer"
3466
+ }
3467
+ }), t("settings.custom")]
3468
+ })]
3469
+ })]
3470
+ }),
3471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3472
+ style: {
3473
+ display: "flex",
3474
+ alignItems: "center",
3475
+ gap: 10,
3476
+ flexWrap: "wrap"
3477
+ },
3478
+ children: [
3479
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3480
+ style: {
3481
+ fontSize: 12.5,
3482
+ color: "#8b96ab",
3483
+ width: 90,
3484
+ flexShrink: 0
3485
+ },
3486
+ children: t("settings.iconSize")
3487
+ }),
3488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3489
+ type: "range",
3490
+ "data-icon-size": true,
3491
+ min: 20,
3492
+ max: 36,
3493
+ step: 2,
3494
+ value: iconSize,
3495
+ onChange: (e) => updatePrefs({ iconSize: Number(e.target.value) }),
3496
+ style: {
3497
+ flex: 1,
3498
+ minWidth: 140,
3499
+ maxWidth: 260
3500
+ }
3501
+ }),
3502
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3503
+ "data-icon-size-value": true,
3504
+ style: {
3505
+ fontSize: 12.5,
3506
+ color: "#b9c2d4",
3507
+ width: 40
3508
+ },
3509
+ children: [iconSize, "px"]
3510
+ })
3511
+ ]
3512
+ }),
3513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3514
+ style: {
3515
+ display: "flex",
3516
+ alignItems: "center",
3517
+ gap: 10,
3518
+ flexWrap: "wrap"
3519
+ },
3520
+ children: [
3521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3522
+ style: {
3523
+ fontSize: 12.5,
3524
+ color: "#8b96ab",
3525
+ width: 90,
3526
+ flexShrink: 0
3527
+ },
3528
+ children: t("settings.inset")
3529
+ }),
3530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3531
+ type: "range",
3532
+ "data-inset": true,
3533
+ min: 0,
3534
+ max: 40,
3535
+ step: 2,
3536
+ value: inset,
3537
+ onChange: (e) => updatePrefs({ inset: Number(e.target.value) }),
3538
+ style: {
3539
+ flex: 1,
3540
+ minWidth: 140,
3541
+ maxWidth: 260
3542
+ }
3543
+ }),
3544
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3545
+ "data-inset-value": true,
3546
+ style: {
3547
+ fontSize: 12.5,
3548
+ color: "#b9c2d4",
3549
+ width: 40
3550
+ },
3551
+ children: [inset, "px"]
3552
+ })
3553
+ ]
3554
+ }),
3555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3556
+ role: "radiogroup",
3557
+ "aria-label": t("settings.side"),
3558
+ style: {
3559
+ display: "flex",
3560
+ alignItems: "center",
3561
+ gap: 10,
3562
+ flexWrap: "wrap"
3563
+ },
3564
+ children: [
3565
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3566
+ style: {
3567
+ fontSize: 12.5,
3568
+ color: "#8b96ab",
3569
+ width: 90,
3570
+ flexShrink: 0
3571
+ },
3572
+ children: t("settings.side")
3573
+ }),
3574
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3575
+ style: {
3576
+ display: "inline-flex",
3577
+ alignItems: "center",
3578
+ gap: 5,
3579
+ fontSize: 13,
3580
+ color: "#e6e8ee",
3581
+ cursor: "pointer"
3582
+ },
3583
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3584
+ type: "radio",
3585
+ name: "ms-rail-side",
3586
+ "data-side-radio": true,
3587
+ value: "left",
3588
+ checked: side === "left",
3589
+ onChange: () => updatePrefs({ side: "left" })
3590
+ }), t("settings.side.left")]
3591
+ }),
3592
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3593
+ style: {
3594
+ display: "inline-flex",
3595
+ alignItems: "center",
3596
+ gap: 5,
3597
+ fontSize: 13,
3598
+ color: "#e6e8ee",
3599
+ cursor: "pointer"
3600
+ },
3601
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3602
+ type: "radio",
3603
+ name: "ms-rail-side",
3604
+ "data-side-radio": true,
3605
+ value: "right",
3606
+ checked: side === "right",
3607
+ onChange: () => updatePrefs({ side: "right" })
3608
+ }), t("settings.side.right")]
3609
+ })
3610
+ ]
3611
+ })
3612
+ ]
3613
+ })]
3614
+ }),
3615
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3616
+ "data-settings-section": true,
3617
+ "data-settings-lang": true,
3618
+ style: { marginBottom: 14 },
3619
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3620
+ "data-settings-section-title": true,
3621
+ style: {
3622
+ fontSize: 13,
3623
+ fontWeight: 600,
3624
+ color: "#c7cede",
3625
+ marginBottom: 8
3626
+ },
3627
+ children: t("settings.language")
3628
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3629
+ role: "radiogroup",
3630
+ "aria-label": t("settings.language"),
3631
+ style: {
3632
+ display: "flex",
3633
+ alignItems: "center",
3634
+ gap: 18,
3635
+ flexWrap: "wrap"
3636
+ },
3637
+ children: [
3638
+ "system",
3639
+ "zh",
3640
+ "en"
3641
+ ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3642
+ style: {
3643
+ display: "inline-flex",
3644
+ alignItems: "center",
3645
+ gap: 5,
3646
+ fontSize: 13,
3647
+ color: "#e6e8ee",
3648
+ cursor: "pointer"
3649
+ },
3650
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3651
+ type: "radio",
3652
+ name: "ms-rail-locale",
3653
+ "data-locale-pref": true,
3654
+ value,
3655
+ checked: prefs.locale === value,
3656
+ onChange: () => updatePrefs({ locale: value })
3657
+ }), t(`settings.lang.${value}`)]
3658
+ }, value))
3659
+ })]
3660
+ }),
3661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3662
+ "data-toolbar-settings-footer": true,
3663
+ style: { textAlign: "center" },
3664
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3665
+ style: {
3666
+ fontSize: 12,
3667
+ color: "#8b96ab",
3668
+ marginBottom: 10
3669
+ },
3670
+ children: t("settings.support")
3671
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3672
+ "data-support-grid": true,
3673
+ style: {
3674
+ display: "grid",
3675
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
3676
+ gap: 10,
3677
+ maxWidth: 460,
3678
+ margin: "0 auto"
3679
+ },
3680
+ children: [
3681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3682
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
3683
+ target: "_blank",
3684
+ rel: "noreferrer",
3685
+ "data-support-card": true,
3686
+ "data-card": "repo",
3687
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3688
+ width: "14",
3689
+ height: "14",
3690
+ viewBox: "0 0 24 24",
3691
+ fill: "none",
3692
+ stroke: "currentColor",
3693
+ strokeWidth: "2",
3694
+ strokeLinecap: "round",
3695
+ strokeLinejoin: "round",
3696
+ "aria-hidden": "true",
3697
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" })
3698
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.repo") })]
3699
+ }),
3700
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3701
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
3702
+ target: "_blank",
3703
+ rel: "noreferrer",
3704
+ "data-support-card": true,
3705
+ "data-card": "star",
3706
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3707
+ width: "14",
3708
+ height: "14",
3709
+ viewBox: "0 0 24 24",
3710
+ fill: "currentColor",
3711
+ stroke: "none",
3712
+ "aria-hidden": "true",
3713
+ 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" })
3714
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.star") })]
3715
+ }),
3716
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3717
+ href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
3718
+ target: "_blank",
3719
+ rel: "noreferrer",
3720
+ "data-support-card": true,
3721
+ "data-card": "issues",
3722
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3723
+ width: "14",
3724
+ height: "14",
3725
+ viewBox: "0 0 24 24",
3726
+ fill: "none",
3727
+ stroke: "currentColor",
3728
+ strokeWidth: "2",
3729
+ "aria-hidden": "true",
3730
+ children: [
3731
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3732
+ cx: "12",
3733
+ cy: "12",
3734
+ r: "9"
3735
+ }),
3736
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
3737
+ d: "M12 8v4",
3738
+ strokeLinecap: "round"
3739
+ }),
3740
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3741
+ cx: "12",
3742
+ cy: "16",
3743
+ r: "0.5",
3744
+ fill: "currentColor"
3745
+ })
3746
+ ]
3747
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.issues") })]
3748
+ }),
3749
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3750
+ href: "https://www.npmjs.com/package/dsh-milestone",
3751
+ target: "_blank",
3752
+ rel: "noreferrer",
3753
+ "data-support-card": true,
3754
+ "data-card": "npm",
3755
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3756
+ width: "14",
3757
+ height: "14",
3758
+ viewBox: "0 0 24 24",
3759
+ fill: "currentColor",
3760
+ stroke: "none",
3761
+ "aria-hidden": "true",
3762
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M2 8.5h20V15h-6v2.5h-3V15H2V8.5zm1.5 1.5v3.5H6V11.5h1.5v3.5h1.5V10h-4.5zm6 0v5h3V13h2v2h1.5v-5h-6.5z" })
3763
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.npm") })]
3764
+ })
3765
+ ]
3766
+ })]
3767
+ }),
3768
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3769
+ type: "button",
3770
+ "data-toolbar-settings-reset": true,
3771
+ onClick: onResetAll,
3772
+ style: {
3773
+ display: "block",
3774
+ margin: "14px auto 0",
3775
+ padding: "7px 16px",
3776
+ background: "rgba(255, 255, 255, 0.06)",
3777
+ border: "1px solid rgba(255, 255, 255, 0.14)",
3778
+ borderRadius: 8,
3779
+ cursor: "pointer",
3780
+ color: "#b9c2d4",
3781
+ fontSize: 12.5
3782
+ },
3783
+ children: t("settings.reset")
3784
+ })
1817
3785
  ]
1818
3786
  })
1819
3787
  }),
1820
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
1821
- panelTop: railBox.top,
1822
- panelRight: railBox.right + DOT_HIT + 8,
1823
- query: search.query,
1824
- panelOpen: search.panelOpen,
1825
- matches: matches.length,
1826
- total: displayMarks.length,
1827
- onToggle: () => setSearch((s) => ({
1828
- ...s,
1829
- panelOpen: !s.panelOpen
1830
- })),
1831
- onQueryChange: updateQuery,
1832
- onSearchKeyDown,
1833
- onClear: clearSearch,
1834
- t
3788
+ updateOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3789
+ ref: updatePanelRef,
3790
+ "data-update-panel": true,
3791
+ style: {
3792
+ position: "fixed",
3793
+ top: railBox.top,
3794
+ right: panelRightFor(PANEL_WIDTH_STANDARD),
3795
+ width: "min(280px, calc(100vw - 48px))",
3796
+ padding: "10px 12px",
3797
+ background: "rgba(20, 24, 32, 0.97)",
3798
+ color: "#e6e8ee",
3799
+ borderRadius: 8,
3800
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
3801
+ zIndex: 104
3802
+ },
3803
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3804
+ "data-update-title": true,
3805
+ style: {
3806
+ fontSize: 13,
3807
+ fontWeight: 600,
3808
+ color: "#e6e8ee",
3809
+ marginBottom: 8
3810
+ },
3811
+ children: t("update.title")
3812
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3813
+ style: {
3814
+ display: "flex",
3815
+ flexDirection: "column",
3816
+ gap: 6,
3817
+ fontSize: 12,
3818
+ lineHeight: 1.5
3819
+ },
3820
+ children: [
3821
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3822
+ style: { color: "#8b96ab" },
3823
+ children: [t("update.current"), ": "]
3824
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.2" })] }),
3825
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3826
+ "data-update-latest": true,
3827
+ children: [
3828
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3829
+ style: { color: "#8b96ab" },
3830
+ children: [t("update.latest"), ": "]
3831
+ }),
3832
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: updateCheck.latest }),
3833
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3834
+ style: { color: "#8b96ab" },
3835
+ children: [
3836
+ " (",
3837
+ updateCheck.source,
3838
+ ")"
3839
+ ]
3840
+ })
3841
+ ]
3842
+ }),
3843
+ updateCheck.phase === "checking" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3844
+ "data-update-status": true,
3845
+ style: { color: "#8b96ab" },
3846
+ children: t("update.checking")
3847
+ }),
3848
+ updateCheck.phase === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3849
+ "data-update-failed": true,
3850
+ children: [
3851
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [t("update.failed"), ":"] }),
3852
+ " ",
3853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3854
+ style: { color: "#8b96ab" },
3855
+ children: updateCheck.error
3856
+ }),
3857
+ " ",
3858
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3859
+ type: "button",
3860
+ "data-update-retry": true,
3861
+ onClick: runUpdateCheck,
3862
+ style: {
3863
+ background: "transparent",
3864
+ border: "none",
3865
+ padding: 0,
3866
+ cursor: "pointer",
3867
+ color: accentSoft,
3868
+ fontSize: 12,
3869
+ textDecoration: "underline"
3870
+ },
3871
+ children: t("update.retry")
3872
+ })
3873
+ ]
3874
+ }),
3875
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3876
+ "data-update-conclusion": true,
3877
+ children: updateCheck.available ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
3878
+ t("update.available"),
3879
+ " v",
3880
+ updateCheck.latest,
3881
+ " → "
3882
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
3883
+ href: "https://www.npmjs.com/package/dsh-milestone",
3884
+ target: "_blank",
3885
+ rel: "noreferrer",
3886
+ style: {
3887
+ color: accentSoft,
3888
+ textDecoration: "none"
3889
+ },
3890
+ children: t("update.goNpm")
3891
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3892
+ style: { color: "#7ee2a8" },
3893
+ children: t("update.upToDate")
3894
+ })
3895
+ }),
3896
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3897
+ "data-update-host-lines": true,
3898
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3899
+ style: { color: "#8b96ab" },
3900
+ children: [t("update.hostLines"), ": "]
3901
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: SUPPORTED_HOST_LINES.map(hostLineLabel).join("、") })]
3902
+ }),
3903
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3904
+ type: "button",
3905
+ "data-update-manual": true,
3906
+ disabled: updateCheck.phase === "checking",
3907
+ onClick: runUpdateCheck,
3908
+ style: {
3909
+ marginTop: 4,
3910
+ padding: "6px 10px",
3911
+ background: updateCheck.phase === "checking" ? "transparent" : accentBg,
3912
+ border: "none",
3913
+ borderRadius: 6,
3914
+ cursor: updateCheck.phase === "checking" ? "default" : "pointer",
3915
+ color: updateCheck.phase === "checking" ? "#5a6375" : accentSoft,
3916
+ fontSize: 12,
3917
+ alignSelf: "flex-start"
3918
+ },
3919
+ children: updateCheck.phase === "checking" ? t("update.checking") : t("update.check")
3920
+ })
3921
+ ]
3922
+ })]
1835
3923
  }),
1836
3924
  listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
1837
3925
  panelTop: railBox.top,
1838
- panelRight: railBox.right + DOT_HIT + 8,
3926
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
1839
3927
  marks,
1840
3928
  onJump: jump,
1841
3929
  t
1842
3930
  }),
1843
3931
  crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
1844
3932
  panelTop: railBox.top,
1845
- panelRight: railBox.right + DOT_HIT + 8,
3933
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
1846
3934
  onClose: () => setCrossOpen(false),
1847
3935
  searchSessions,
1848
3936
  openSession,
@@ -1862,12 +3950,12 @@ window.__ModuleLoader__.load({
1862
3950
  display: "flex",
1863
3951
  flexDirection: "column",
1864
3952
  alignItems: "center",
1865
- gap: DOT_GAP,
3953
+ gap,
1866
3954
  padding: "6px 0",
1867
3955
  scrollbarWidth: "none"
1868
3956
  },
1869
3957
  children: render.items.map((item, i) => {
1870
- const showSeparator = separatorIndices.has(i);
3958
+ const showGroupGap = separatorIndices.has(i) && i > 0;
1871
3959
  const mark = displayMarks[item.displayIndex];
1872
3960
  const summaryCount = collapsedSummaries.get(mark.key);
1873
3961
  const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
@@ -1879,7 +3967,7 @@ window.__ModuleLoader__.load({
1879
3967
  isCurrent: !hasQuery && mark.key === currentKey
1880
3968
  });
1881
3969
  const isHovered = hover?.mark.key === mark.key;
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";
3970
+ const boxShadow = isHovered ? `0 0 0 3px ${rgbaString(accent, .35) ?? "rgba(77, 124, 254, 0.35)"}` : dotState === "active" ? `0 0 0 3px rgba(255, 255, 255, 0.9), 0 0 10px 2px ${rgbaString(accent, .55) ?? "rgba(77, 124, 254, 0.55)"}` : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : dotState === "match" ? `0 0 0 2px ${rgbaString(accent, .45) ?? "rgba(77, 124, 254, 0.45)"}` : "none";
1883
3971
  const badge = deriveBadge({
1884
3972
  nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
1885
3973
  lastMark: item.displayIndex === displayMarks.length - 1,
@@ -1887,21 +3975,11 @@ window.__ModuleLoader__.load({
1887
3975
  awaitingInput
1888
3976
  });
1889
3977
  const ringStyle = badge === null ? null : badgeRingStyle(badge);
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", {
3978
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1901
3979
  type: "button",
1902
3980
  style: {
1903
- width: DOT_HIT,
1904
- height: DOT_HIT,
3981
+ width: hit,
3982
+ height: hit,
1905
3983
  flexShrink: 0,
1906
3984
  display: "flex",
1907
3985
  alignItems: "center",
@@ -1909,7 +3987,8 @@ window.__ModuleLoader__.load({
1909
3987
  background: "transparent",
1910
3988
  border: "none",
1911
3989
  padding: 0,
1912
- cursor: "pointer"
3990
+ cursor: "pointer",
3991
+ marginTop: showGroupGap ? GROUP_GAP_EXTRA : 0
1913
3992
  },
1914
3993
  onMouseEnter: (e) => {
1915
3994
  const rect = e.currentTarget.getBoundingClientRect();
@@ -1920,6 +3999,8 @@ window.__ModuleLoader__.load({
1920
3999
  },
1921
4000
  onClick: () => jump(mark.key),
1922
4001
  "data-rail-dot": true,
4002
+ "data-turn-gap": showGroupGap ? "true" : void 0,
4003
+ "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
1923
4004
  "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
1924
4005
  "data-collapsed-count": summaryCount,
1925
4006
  tabIndex: focusIndex === i ? 0 : -1,
@@ -1931,10 +4012,10 @@ window.__ModuleLoader__.load({
1931
4012
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1932
4013
  style: {
1933
4014
  position: "relative",
1934
- width: DOT_SIZE,
1935
- height: DOT_SIZE,
4015
+ width: size,
4016
+ height: size,
1936
4017
  borderRadius: "50%",
1937
- background: dotColor(item.displayIndex, marks.length),
4018
+ background: dotColor(item.displayIndex, marks.length, accent),
1938
4019
  boxShadow,
1939
4020
  transition: "transform 120ms ease, opacity 120ms ease",
1940
4021
  transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
@@ -1947,18 +4028,18 @@ window.__ModuleLoader__.load({
1947
4028
  position: "absolute",
1948
4029
  inset: -3,
1949
4030
  borderRadius: "50%",
1950
- border: `2px solid ${ringStyle.color}`,
4031
+ boxShadow: ringStyle.shadow,
1951
4032
  color: ringStyle.color,
1952
4033
  pointerEvents: "none",
1953
- animation: ringStyle.pulse ? "milestone-badge-pulse 1.4s ease-out infinite" : void 0
4034
+ animation: ringStyle.pulse ? "milestone-badge-pulse 2s ease-in-out infinite" : void 0
1954
4035
  }
1955
4036
  })
1956
4037
  })
1957
- })] }, mark.key);
4038
+ }) }, mark.key);
1958
4039
  })
1959
4040
  }),
1960
4041
  hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
1961
- panelRight: railBox.right + DOT_HIT + 8,
4042
+ panelRight: panelRightFor(TOOLTIP_ANCHOR_WIDTH),
1962
4043
  hover,
1963
4044
  bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
1964
4045
  onToggleBookmark: () => onToggleBookmark(hover.mark.key),
@@ -1977,8 +4058,13 @@ window.__ModuleLoader__.load({
1977
4058
  style: {
1978
4059
  position: "absolute",
1979
4060
  bottom: 6,
1980
- right: "100%",
1981
- marginRight: 8,
4061
+ ...side === "left" ? {
4062
+ left: "100%",
4063
+ marginLeft: 8
4064
+ } : {
4065
+ right: "100%",
4066
+ marginRight: 8
4067
+ },
1982
4068
  whiteSpace: "nowrap",
1983
4069
  fontSize: 12,
1984
4070
  lineHeight: 1,
@@ -2101,144 +4187,6 @@ window.__ModuleLoader__.load({
2101
4187
  });
2102
4188
  }
2103
4189
  //#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
- };
2241
- //#endregion
2242
4190
  //#region src/client/index.ts
2243
4191
  /** Required services (cordis fiber inject). */
2244
4192
  const inject = [