dsh-milestone 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -0
  2. package/lib/client.js +1894 -59
  3. package/package.json +9 -9
package/lib/client.js CHANGED
@@ -149,6 +149,59 @@ window.__ModuleLoader__.load({
149
149
  }
150
150
  }
151
151
  //#endregion
152
+ //#region src/client/deep-link-logic.ts
153
+ /**
154
+ * Pure deep-link helpers for the milestone rail: parse the `#msg=<key>` URL
155
+ * hash and rebuild it.
156
+ *
157
+ * The conversation anchor key is treated as an OPAQUE string — it is a
158
+ * length-prefixed node key like `13:input-message<messageId>` or
159
+ * `14:assistant-step3:2` (the length prefix makes naive splitting ambiguous),
160
+ * so the parser never inspects or splits the key itself. Callers match the
161
+ * returned key against the session's marks, which is the real validity check.
162
+ *
163
+ * Percent-encoding: the URL fragment parser percent-encodes `"` `<` `>` `` ` ``
164
+ * and lone `%`, so a hash built with a RAW `<`-containing key would read back
165
+ * from `location.hash` percent-encoded (`13:user%3Cdl-2%3E`) and never match
166
+ * a mark after a refresh. `buildMessageHash` therefore percent-encodes those
167
+ * characters itself (a byte-exact URL), and `parseDeepLinkHash` decodes them
168
+ * back — the key round-trips through the URL untouched.
169
+ */
170
+ /** The URL hash fragment prefix that carries a message anchor key. */
171
+ const MSG_HASH_PREFIX = "msg=";
172
+ /**
173
+ * Characters the WHATWG URL fragment parser cannot round-trip raw (they are
174
+ * percent-encoded on parse): `"` (0x22), `<` (0x3C), `>` (0x3E), backtick
175
+ * (0x60), and `%` (0x25, so a literal `%` never reads as an escape start).
176
+ */
177
+ const FRAGMENT_UNSAFE = /["<>\u0060%]/g;
178
+ /**
179
+ * Parse a `location.hash` fragment into the message anchor key it references.
180
+ * @param hash - the raw `location.hash` value: `''` or a fragment starting
181
+ * with `#` (e.g. `#msg=13:input-messageabc`).
182
+ * @returns the anchor key (percent-escapes decoded), or null when the
183
+ * fragment is not a message deep link: empty hash, `#msg=` with an empty
184
+ * value, a `#msg` prefix without the `=`, any other hash shape, a hash
185
+ * missing the leading `#`, or a malformed percent escape.
186
+ */
187
+ function parseDeepLinkHash(hash) {
188
+ if (!hash.startsWith(`#${MSG_HASH_PREFIX}`)) return null;
189
+ const encoded = hash.slice(5);
190
+ if (encoded === "") return null;
191
+ try {
192
+ return decodeURIComponent(encoded);
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+ /**
198
+ * Build the URL hash fragment that deep-links to a message anchor key.
199
+ * @param key - the conversation node key (opaque, never parsed here).
200
+ */
201
+ function buildMessageHash(key) {
202
+ return `#${MSG_HASH_PREFIX}${key.replace(FRAGMENT_UNSAFE, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
203
+ }
204
+ //#endregion
152
205
  //#region src/client/label-logic.ts
153
206
  const MINUTE_MS = 6e4;
154
207
  const HOUR_MS = 36e5;
@@ -450,13 +503,110 @@ window.__ModuleLoader__.load({
450
503
  };
451
504
  }
452
505
  //#endregion
506
+ //#region src/client/useOutsideDismiss.ts
507
+ /**
508
+ * useOutsideDismiss: the shared "click outside to dismiss" contract behind the
509
+ * rail's floating panels (in-rail search / all-prompts list / cross-session
510
+ * search). While a panel is open, a pointerdown anywhere OUTSIDE it calls the
511
+ * caller's close handler; a pointerdown inside the panel (or on an excluded
512
+ * element) is left untouched.
513
+ *
514
+ * Event choice — window `pointerdown`:
515
+ * - `pointerdown` unifies mouse / touch / pen, so dismissal works for every
516
+ * input the harness surfaces; a `mousedown`-only listener would miss
517
+ * touch taps entirely.
518
+ * - Closing on the DOWN side of the gesture feels instant — the panel is
519
+ * gone before the pointer is released, which is the expected behaviour
520
+ * for fixed floating layers.
521
+ *
522
+ * Lifecycle: the listener is attached ONLY while `open` and removed on close
523
+ * / unmount, so a closed panel never intercepts events and no listener leaks.
524
+ *
525
+ * Opening-gesture guard: hooks arm their listener in an effect, which React
526
+ * runs AFTER the toggle's pointerdown/click that opened the panel — that
527
+ * gesture can therefore never reach the listener by construction. As
528
+ * defense-in-depth the hook also records its arming time and drops any
529
+ * pointerdown whose `timeStamp` STRICTLY predates it (an event still in
530
+ * flight from the opening gesture). Same-tick and later events pass through,
531
+ * and synthetic test events with `timeStamp === 0` (which carry no real
532
+ * timestamp) are kept — the guard never swallows a legitimate dispatch.
533
+ *
534
+ * `options.exclude` names targets the caller keeps for its own handler — the
535
+ * panel's own toggle button is the canonical case: its click owns the
536
+ * open/close flip, so a pointerdown on it must NOT also dismiss through the
537
+ * hook, or clicking an armed toggle would close on pointerdown and re-open on
538
+ * click.
539
+ */
540
+ /**
541
+ * True when `target` is (or sits inside) an element matching `selector`.
542
+ * Panels use it to exclude their own rail-top toggle from dismissal (the
543
+ * toggle's data attribute is the established rail DOM contract, so the
544
+ * exclusion is just another access to it).
545
+ */
546
+ function outsideDismissMatches(target, selector) {
547
+ return target instanceof Element && target.closest(selector) !== null;
548
+ }
549
+ /**
550
+ * @param panelRef - the floating panel's root element (only mounted while open).
551
+ * @param open - whether the panel is open; the window listener arms only when true.
552
+ * @param onClose - called once per outside pointerdown. `undefined` keeps the
553
+ * hook inert — used while a call site still has no close path wired.
554
+ * @param options - optional exclusion predicate (see {@link OutsideDismissOptions}).
555
+ */
556
+ function useOutsideDismiss(panelRef, open, onClose, options) {
557
+ const onCloseRef = (0, react.useRef)(onClose);
558
+ const excludeRef = (0, react.useRef)(options?.exclude);
559
+ (0, react.useEffect)(() => {
560
+ onCloseRef.current = onClose;
561
+ excludeRef.current = options?.exclude;
562
+ });
563
+ (0, react.useEffect)(() => {
564
+ if (!open) return;
565
+ const armedAt = performance.now();
566
+ const onPointerDown = (e) => {
567
+ const onClose = onCloseRef.current;
568
+ if (onClose === void 0) return;
569
+ if (e.timeStamp > 0 && e.timeStamp < armedAt) return;
570
+ if (excludeRef.current?.(e.target)) return;
571
+ const panel = panelRef.current;
572
+ if (panel !== null && e.target instanceof Node && panel.contains(e.target)) return;
573
+ onClose();
574
+ };
575
+ window.addEventListener("pointerdown", onPointerDown);
576
+ return () => window.removeEventListener("pointerdown", onPointerDown);
577
+ }, [open, panelRef]);
578
+ }
579
+ //#endregion
453
580
  //#region src/client/MilestoneRailSearch.tsx
581
+ /**
582
+ * RailSearchUi: the in-rail search chrome (F1) — the magnifier toggle pinned
583
+ * to the rail's top and the compact search panel to its left (input, match
584
+ * counter, clear button).
585
+ *
586
+ * Pure presentation: it owns no state. MilestoneRail holds the search state
587
+ * and handlers and feeds them in as props, so the search lifecycle (query,
588
+ * match cycle, escape semantics) stays component-local in the rail. Splitting
589
+ * the chrome into its own file keeps the rail component under the size
590
+ * ceiling while the two still render one DOM contract
591
+ * (`data-search-toggle` / `data-rail-search` / `data-match-count` /
592
+ * `data-search-clear`).
593
+ *
594
+ * Outside dismissal: while the panel is open, a pointerdown anywhere outside
595
+ * it closes it (shared useOutsideDismiss hook). The toggle's own click keeps
596
+ * its flip semantics — a pointerdown on `[data-search-toggle]` is excluded
597
+ * from the hook and left to the rail's onToggle. Without a dedicated onClose
598
+ * prop the fallback is the toggle-off path (query retained, same as clicking
599
+ * the toggle), matching the rail's current call site; a dedicated onClose
600
+ * (clearSearch-equivalent) takes precedence once MilestoneRail feeds one.
601
+ */
454
602
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
455
- const DOT_HIT$1 = 22;
603
+ const DOT_HIT$1 = 28;
456
604
  /**
457
605
  * @param props - the search state slice plus the rail's event handlers.
458
606
  */
459
- function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
607
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, onClose, t }) {
608
+ const panelRef = (0, react.useRef)(null);
609
+ useOutsideDismiss(panelRef, panelOpen, onClose ?? (() => onToggle()), { exclude: (target) => outsideDismissMatches(target, "[data-search-toggle]") });
460
610
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
461
611
  type: "button",
462
612
  "data-search-toggle": true,
@@ -477,8 +627,8 @@ window.__ModuleLoader__.load({
477
627
  color: panelOpen ? "#9db8ff" : "#8b96ab"
478
628
  },
479
629
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
480
- width: "13",
481
- height: "13",
630
+ width: "16",
631
+ height: "16",
482
632
  viewBox: "0 0 24 24",
483
633
  fill: "none",
484
634
  stroke: "currentColor",
@@ -492,11 +642,12 @@ window.__ModuleLoader__.load({
492
642
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21 21-4.3-4.3" })]
493
643
  })
494
644
  }), panelOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
645
+ ref: panelRef,
495
646
  style: {
496
647
  position: "fixed",
497
648
  top: panelTop,
498
649
  right: panelRight,
499
- width: 220,
650
+ width: "min(220px, calc(100vw - 48px))",
500
651
  padding: "10px 12px",
501
652
  background: "rgba(20, 24, 32, 0.97)",
502
653
  color: "#e6e8ee",
@@ -521,8 +672,9 @@ window.__ModuleLoader__.load({
521
672
  style: {
522
673
  flex: 1,
523
674
  minWidth: 0,
524
- padding: "5px 8px",
525
- fontSize: 12,
675
+ padding: "6px 10px",
676
+ fontSize: 14,
677
+ lineHeight: 1.4,
526
678
  color: "#e6e8ee",
527
679
  background: "rgba(255, 255, 255, 0.08)",
528
680
  border: "1px solid rgba(255, 255, 255, 0.16)",
@@ -548,8 +700,8 @@ window.__ModuleLoader__.load({
548
700
  color: "#8b96ab"
549
701
  },
550
702
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
551
- width: "10",
552
- height: "10",
703
+ width: "12",
704
+ height: "12",
553
705
  viewBox: "0 0 24 24",
554
706
  fill: "none",
555
707
  stroke: "currentColor",
@@ -563,7 +715,7 @@ window.__ModuleLoader__.load({
563
715
  "data-match-count": true,
564
716
  style: {
565
717
  marginTop: 6,
566
- fontSize: 11,
718
+ fontSize: 13,
567
719
  color: "#8b96ab"
568
720
  },
569
721
  children: [
@@ -575,6 +727,124 @@ window.__ModuleLoader__.load({
575
727
  })] });
576
728
  }
577
729
  //#endregion
730
+ //#region src/client/MilestoneListPanel.tsx
731
+ /**
732
+ * MilestoneListPanel: the expandable all-prompts panel (P3) — the compact
733
+ * list chrome pinned to the rail's top that enumerates EVERY user-prompt
734
+ * milestone (序号 + turn + preview), independent of the search/bookmarks
735
+ * filters. Clicking an entry jumps to that message through the rail's own
736
+ * `jump` handler (the same path the dots use).
737
+ *
738
+ * Pure presentation: it owns no state. MilestoneRail holds the `listOpen`
739
+ * boolean (the toggle and the Escape/close semantics live there) and feeds
740
+ * the panel its anchor, the full marks array, and the jump handler as props
741
+ * — same split as RailSearchUi. Renders one DOM contract
742
+ * (`data-milestone-list` root / `data-list-item` rows with `data-jump-key`).
743
+ *
744
+ * Outside dismissal: while the panel is mounted (it only renders while open),
745
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
746
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
747
+ * through a `[data-list-toggle]` exclusion). MilestoneRail.tsx does NOT pass
748
+ * onClose in the current tree (its owner is wiring it separately) — until
749
+ * then the hook is inert and the panel keeps its existing behaviour.
750
+ */
751
+ /**
752
+ * @param props - the panel anchor, the full marks array, and the rail's jump handler.
753
+ */
754
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, onClose, t }) {
755
+ const panelRef = (0, react.useRef)(null);
756
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-list-toggle]") });
757
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
758
+ ref: panelRef,
759
+ "data-milestone-list": true,
760
+ style: {
761
+ position: "fixed",
762
+ top: panelTop,
763
+ right: panelRight,
764
+ width: "min(280px, calc(100vw - 48px))",
765
+ padding: "10px 12px",
766
+ background: "rgba(20, 24, 32, 0.97)",
767
+ color: "#e6e8ee",
768
+ borderRadius: 8,
769
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
770
+ zIndex: 103
771
+ },
772
+ children: [
773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-list-item]:hover { background: rgba(77, 124, 254, 0.18); }` }),
774
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
775
+ style: {
776
+ display: "flex",
777
+ alignItems: "center",
778
+ gap: 6,
779
+ marginBottom: 6
780
+ },
781
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
782
+ style: {
783
+ fontSize: 13,
784
+ fontWeight: 600,
785
+ color: "#e6e8ee"
786
+ },
787
+ children: t("list.label")
788
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
789
+ style: {
790
+ fontSize: 12,
791
+ color: "#8b96ab"
792
+ },
793
+ children: marks.length
794
+ })]
795
+ }),
796
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
797
+ style: {
798
+ maxHeight: 300,
799
+ overflowY: "auto",
800
+ display: "flex",
801
+ flexDirection: "column",
802
+ gap: 2
803
+ },
804
+ children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
805
+ type: "button",
806
+ "data-list-item": true,
807
+ "data-jump-key": mark.key,
808
+ onClick: () => onJump(mark.key),
809
+ title: mark.preview,
810
+ style: {
811
+ display: "block",
812
+ width: "100%",
813
+ minWidth: 0,
814
+ padding: "6px 8px",
815
+ background: "transparent",
816
+ border: "none",
817
+ borderRadius: 6,
818
+ cursor: "pointer",
819
+ color: "#e6e8ee",
820
+ textAlign: "left"
821
+ },
822
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
823
+ style: {
824
+ fontSize: 12,
825
+ color: "#8b96ab",
826
+ whiteSpace: "nowrap"
827
+ },
828
+ children: [t("pos.of", {
829
+ n: i + 1,
830
+ m: marks.length
831
+ }), mark.turn !== void 0 ? ` · ${t("turn.label", { n: mark.turn })}` : null]
832
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
833
+ style: {
834
+ fontSize: 13,
835
+ lineHeight: 1.4,
836
+ overflow: "hidden",
837
+ textOverflow: "ellipsis",
838
+ whiteSpace: "nowrap"
839
+ },
840
+ children: mark.preview || t("no.text")
841
+ })]
842
+ }, mark.key))
843
+ })
844
+ ]
845
+ });
846
+ }
847
+ //#endregion
578
848
  //#region src/client/MilestoneRailTooltip.tsx
579
849
  /**
580
850
  * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
@@ -591,14 +861,14 @@ window.__ModuleLoader__.load({
591
861
  right: panelRight,
592
862
  top: hover.top,
593
863
  transform: "translateY(-50%)",
594
- maxWidth: 300,
864
+ maxWidth: "min(300px, calc(100vw - 120px))",
595
865
  minWidth: 180,
596
866
  padding: "8px 12px",
597
867
  background: "rgba(20, 24, 32, 0.96)",
598
868
  color: "#e6e8ee",
599
869
  borderRadius: 8,
600
- fontSize: 12,
601
- lineHeight: 1.6,
870
+ fontSize: "var(--dsw-font-s-14, 14px)",
871
+ lineHeight: 1.5,
602
872
  whiteSpace: "pre-wrap",
603
873
  wordBreak: "break-word",
604
874
  boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
@@ -613,7 +883,8 @@ window.__ModuleLoader__.load({
613
883
  flexWrap: "wrap",
614
884
  gap: 8,
615
885
  color: "#9aa4b8",
616
- fontSize: 11,
886
+ fontSize: 13,
887
+ lineHeight: 1.4,
617
888
  marginBottom: 4
618
889
  },
619
890
  children: [
@@ -647,8 +918,8 @@ window.__ModuleLoader__.load({
647
918
  color: bookmarked ? "#ffd166" : "#8b96ab"
648
919
  },
649
920
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
650
- width: "13",
651
- height: "13",
921
+ width: "14",
922
+ height: "14",
652
923
  viewBox: "0 0 24 24",
653
924
  fill: bookmarked ? "currentColor" : "none",
654
925
  stroke: "currentColor",
@@ -673,7 +944,7 @@ window.__ModuleLoader__.load({
673
944
  justifyContent: "center",
674
945
  background: "transparent",
675
946
  border: "none",
676
- padding: "2px 6px",
947
+ padding: "3px 8px",
677
948
  cursor: "pointer",
678
949
  whiteSpace: "nowrap",
679
950
  color: copied ? "#7ee2a8" : "#8b96ab"
@@ -695,7 +966,7 @@ window.__ModuleLoader__.load({
695
966
  justifyContent: "center",
696
967
  background: "transparent",
697
968
  border: "none",
698
- padding: "2px 6px",
969
+ padding: "3px 8px",
699
970
  cursor: "pointer",
700
971
  whiteSpace: "nowrap",
701
972
  color: forked ? "#7ee2a8" : "#8b96ab"
@@ -718,7 +989,7 @@ window.__ModuleLoader__.load({
718
989
  justifyContent: "center",
719
990
  background: "transparent",
720
991
  border: "none",
721
- padding: "2px 6px",
992
+ padding: "3px 8px",
722
993
  cursor: "pointer",
723
994
  whiteSpace: "nowrap",
724
995
  color: turnCollapsed ? "#7ee2a8" : "#8b96ab"
@@ -737,7 +1008,8 @@ window.__ModuleLoader__.load({
737
1008
  flexWrap: "wrap",
738
1009
  gap: 8,
739
1010
  color: "#8b96ab",
740
- fontSize: 11,
1011
+ fontSize: 13,
1012
+ lineHeight: 1.4,
741
1013
  marginTop: 4
742
1014
  },
743
1015
  children: [
@@ -754,7 +1026,8 @@ window.__ModuleLoader__.load({
754
1026
  flexWrap: "wrap",
755
1027
  gap: 8,
756
1028
  color: "#8b96ab",
757
- fontSize: 11,
1029
+ fontSize: 13,
1030
+ lineHeight: 1.4,
758
1031
  marginTop: 4
759
1032
  },
760
1033
  children: [
@@ -776,6 +1049,193 @@ window.__ModuleLoader__.load({
776
1049
  });
777
1050
  }
778
1051
  //#endregion
1052
+ //#region src/client/MilestoneSessionSearch.tsx
1053
+ /**
1054
+ * MilestoneSessionSearch: the cross-session search panel (P3) — the fixed
1055
+ * chrome pinned to the rail's top that searches EVERY session's message
1056
+ * content through the injected `searchSessions` action (the harness
1057
+ * `session.search` RPC), lists ranked hits (display title + snippet), and
1058
+ * opens the clicked session via `openSession` (the same selection path the
1059
+ * sidebar uses).
1060
+ *
1061
+ * Owns its search lifecycle — debounce + AbortController + status — unlike
1062
+ * the rail's in-session search (RailSearchUi), which is a pure presentation
1063
+ * slice of MilestoneRail's own state. Mirrors MilestoneListPanel's
1064
+ * fixed-panel styling. Renders one DOM contract
1065
+ * (`data-session-search` root / `data-session-search-input` /
1066
+ * `data-session-search-result` rows / `data-session-search-error` /
1067
+ * `data-session-search-more`).
1068
+ *
1069
+ * Outside dismissal: while the panel is mounted (it only renders while open),
1070
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
1071
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
1072
+ * through a `[data-session-search-toggle]` exclusion).
1073
+ */
1074
+ /** Debounce window for the cross-session query (ms). */
1075
+ const SEARCH_DEBOUNCE_MS = 250;
1076
+ /**
1077
+ * @param props - the panel anchor, the close/open/search actions, and the locale interpreter.
1078
+ */
1079
+ function MilestoneSessionSearch({ panelTop, panelRight, onClose, searchSessions, openSession, t }) {
1080
+ const [query, setQuery] = (0, react.useState)("");
1081
+ const [status, setStatus] = (0, react.useState)("idle");
1082
+ const [hits, setHits] = (0, react.useState)([]);
1083
+ const [hasMore, setHasMore] = (0, react.useState)(false);
1084
+ const panelRef = (0, react.useRef)(null);
1085
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-session-search-toggle]") });
1086
+ (0, react.useEffect)(() => {
1087
+ const trimmed = query.trim();
1088
+ if (trimmed === "") {
1089
+ setStatus("idle");
1090
+ return;
1091
+ }
1092
+ const controller = new AbortController();
1093
+ setStatus("loading");
1094
+ const timer = window.setTimeout(() => {
1095
+ searchSessions(trimmed, controller.signal).then((result) => {
1096
+ if (controller.signal.aborted) return;
1097
+ setHits(result.items);
1098
+ setHasMore(result.hasMore);
1099
+ setStatus(result.items.length > 0 ? "results" : "empty");
1100
+ }, () => {
1101
+ if (controller.signal.aborted) return;
1102
+ setStatus("error");
1103
+ });
1104
+ }, SEARCH_DEBOUNCE_MS);
1105
+ return () => {
1106
+ window.clearTimeout(timer);
1107
+ controller.abort();
1108
+ };
1109
+ }, [query, searchSessions]);
1110
+ const onInputKeyDown = (e) => {
1111
+ if (e.key === "Escape") onClose();
1112
+ };
1113
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1114
+ ref: panelRef,
1115
+ "data-session-search": true,
1116
+ style: {
1117
+ position: "fixed",
1118
+ top: panelTop,
1119
+ right: panelRight,
1120
+ width: "min(280px, calc(100vw - 48px))",
1121
+ padding: "10px 12px",
1122
+ background: "rgba(20, 24, 32, 0.97)",
1123
+ color: "#e6e8ee",
1124
+ borderRadius: 8,
1125
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
1126
+ zIndex: 103
1127
+ },
1128
+ children: [
1129
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-session-search-result]:hover { background: rgba(77, 124, 254, 0.18); }` }),
1130
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1131
+ style: {
1132
+ display: "flex",
1133
+ alignItems: "center",
1134
+ gap: 6,
1135
+ marginBottom: 6
1136
+ },
1137
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1138
+ style: {
1139
+ fontSize: 13,
1140
+ fontWeight: 600,
1141
+ color: "#e6e8ee"
1142
+ },
1143
+ children: t("search.cross")
1144
+ })
1145
+ }),
1146
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1147
+ "data-session-search-input": true,
1148
+ "aria-label": t("search.cross"),
1149
+ placeholder: t("search.cross"),
1150
+ value: query,
1151
+ onChange: (e) => setQuery(e.target.value),
1152
+ onKeyDown: onInputKeyDown,
1153
+ autoFocus: true,
1154
+ style: {
1155
+ boxSizing: "border-box",
1156
+ width: "100%",
1157
+ padding: "6px 10px",
1158
+ fontSize: 14,
1159
+ lineHeight: 1.4,
1160
+ color: "#e6e8ee",
1161
+ background: "rgba(255, 255, 255, 0.08)",
1162
+ border: "1px solid rgba(255, 255, 255, 0.16)",
1163
+ borderRadius: 6,
1164
+ outline: "none"
1165
+ }
1166
+ }),
1167
+ status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1168
+ "data-session-search-error": true,
1169
+ style: {
1170
+ marginTop: 8,
1171
+ fontSize: 13,
1172
+ color: "#f07c7c"
1173
+ },
1174
+ children: t("search.error")
1175
+ }),
1176
+ status === "results" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1177
+ style: {
1178
+ maxHeight: 300,
1179
+ overflowY: "auto",
1180
+ display: "flex",
1181
+ flexDirection: "column",
1182
+ gap: 2,
1183
+ marginTop: 8
1184
+ },
1185
+ children: hits.map((hit) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1186
+ type: "button",
1187
+ "data-session-search-result": true,
1188
+ onClick: () => {
1189
+ openSession(hit.sessionId);
1190
+ onClose();
1191
+ },
1192
+ title: hit.snippet,
1193
+ style: {
1194
+ display: "block",
1195
+ width: "100%",
1196
+ minWidth: 0,
1197
+ padding: "6px 8px",
1198
+ background: "transparent",
1199
+ border: "none",
1200
+ borderRadius: 6,
1201
+ cursor: "pointer",
1202
+ color: "#e6e8ee",
1203
+ textAlign: "left"
1204
+ },
1205
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1206
+ style: {
1207
+ fontSize: 12,
1208
+ color: "#9db8ff",
1209
+ whiteSpace: "nowrap",
1210
+ overflow: "hidden",
1211
+ textOverflow: "ellipsis"
1212
+ },
1213
+ children: hit.title ?? t("search.untitled")
1214
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1215
+ style: {
1216
+ fontSize: 13,
1217
+ lineHeight: 1.4,
1218
+ color: "#c6ccd8",
1219
+ overflow: "hidden",
1220
+ textOverflow: "ellipsis",
1221
+ whiteSpace: "nowrap"
1222
+ },
1223
+ children: hit.snippet
1224
+ })]
1225
+ }, hit.sessionId))
1226
+ }), hasMore && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1227
+ "data-session-search-more": true,
1228
+ style: {
1229
+ marginTop: 6,
1230
+ fontSize: 12,
1231
+ color: "#8b96ab"
1232
+ },
1233
+ children: t("search.more")
1234
+ })] })
1235
+ ]
1236
+ });
1237
+ }
1238
+ //#endregion
779
1239
  //#region src/client/useCurrentAnchor.ts
780
1240
  /**
781
1241
  * useCurrentAnchor: tracks which user-message row sits at/just above the
@@ -838,6 +1298,416 @@ window.__ModuleLoader__.load({
838
1298
  return current;
839
1299
  }
840
1300
  //#endregion
1301
+ //#region src/client/toolbar-prefs.ts
1302
+ /**
1303
+ * toolbar-prefs: the persistence layer for the milestone rail's collapsible
1304
+ * toolbar "pinned outside the collapse" preferences.
1305
+ *
1306
+ * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding
1307
+ * `{ "pinned": string[] }` — the ids of the function-key features the user
1308
+ * wants to keep visible while the toolbar is COLLAPSED. An absent or corrupt
1309
+ * value degrades to `[]` (everything folded away).
1310
+ *
1311
+ * All reads are sanitized: ids are checked against the canonical whitelist
1312
+ * (`TOOLBAR_PIN_IDS`), duplicates are dropped, unknown ids are discarded, and
1313
+ * the pinned order is the caller's first-seen order (the order ids were
1314
+ * pinned) — a future feature removal therefore never leaves a stale id
1315
+ * behind, and a hand-edited value can never smuggle an out-of-registry id
1316
+ * into render state.
1317
+ *
1318
+ * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
1319
+ * dependency-free and unit-testable; MilestoneRail's feature registry keys
1320
+ * itself against the same `ToolbarPinId` type, so id drift is a compile error.
1321
+ */
1322
+ /** The single localStorage key holding the toolbar preference blob. */
1323
+ const TOOLBAR_PREFS_KEY = "dsh-milestone.toolbar";
1324
+ /**
1325
+ * Canonical function-key ids that may be pinned outside the collapse, in
1326
+ * toolbar render order. Adding a feature here (plus its registry entry in
1327
+ * MilestoneRail) is the whole "pin it" extension point.
1328
+ */
1329
+ const TOOLBAR_PIN_IDS = [
1330
+ "search",
1331
+ "list",
1332
+ "sessionSearch",
1333
+ "bookmarks",
1334
+ "focus",
1335
+ "updateCheck"
1336
+ ];
1337
+ /** Type guard for registry ids — unknown strings never survive a parse. */
1338
+ function isToolbarPinId(id) {
1339
+ return TOOLBAR_PIN_IDS.includes(id);
1340
+ }
1341
+ /**
1342
+ * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1343
+ * JSON, or a non-`{pinned: string[]}` shape all degrade to `[]`; unknown ids
1344
+ * and duplicates are dropped; the result keeps the caller's first-seen
1345
+ * (pin) order.
1346
+ */
1347
+ function parsePrefs(raw) {
1348
+ if (raw === null) return [];
1349
+ let parsed;
1350
+ try {
1351
+ parsed = JSON.parse(raw);
1352
+ } catch {
1353
+ return [];
1354
+ }
1355
+ if (typeof parsed !== "object" || parsed === null) return [];
1356
+ const { pinned } = parsed;
1357
+ if (!Array.isArray(pinned)) return [];
1358
+ const seen = /* @__PURE__ */ new Set();
1359
+ const result = [];
1360
+ for (const id of pinned) {
1361
+ if (typeof id !== "string" || !isToolbarPinId(id) || seen.has(id)) continue;
1362
+ seen.add(id);
1363
+ result.push(id);
1364
+ }
1365
+ return result;
1366
+ }
1367
+ /**
1368
+ * Read + sanitize the persisted toolbar prefs from localStorage. Degrades to
1369
+ * `[]` when storage is unavailable (SSR, sandboxed iframe) — pinning is a
1370
+ * best-effort enhancement, never a render blocker.
1371
+ */
1372
+ function loadPrefs() {
1373
+ try {
1374
+ return parsePrefs(localStorage.getItem(TOOLBAR_PREFS_KEY));
1375
+ } catch {
1376
+ return [];
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Persist the pinned ids (sanitized on the way out so a corrupt in-memory
1381
+ * value is never written). Takes plain strings — it cannot trust its input
1382
+ * any more than it trusts localStorage, so unknown ids are dropped, not
1383
+ * rejected. Swallows storage failures for the same best-effort reason as
1384
+ * {@link loadPrefs}.
1385
+ */
1386
+ function savePrefs(pinned) {
1387
+ const cleaned = parsePrefs(JSON.stringify({ pinned: [...pinned] }));
1388
+ try {
1389
+ localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify({ pinned: cleaned }));
1390
+ } catch {}
1391
+ }
1392
+ /**
1393
+ * Pure toggle: adds `id` to the pinned set when absent, removes it when
1394
+ * present. Unknown ids are ignored (the set is returned unchanged) and the
1395
+ * result is always deduped via the parse path, so callers can feed state
1396
+ * straight back into {@link savePrefs}.
1397
+ */
1398
+ function togglePin(pinned, id) {
1399
+ if (!isToolbarPinId(id)) return [...pinned];
1400
+ const next = new Set(pinned);
1401
+ if (next.has(id)) next.delete(id);
1402
+ else next.add(id);
1403
+ return parsePrefs(JSON.stringify({ pinned: [...next] }));
1404
+ }
1405
+ //#endregion
1406
+ //#region src/client/version-logic.ts
1407
+ /**
1408
+ * Pure update-check logic for dsh-milestone, deliberately free of React, the
1409
+ * harness runtime, and any node-only API so it runs identically in the browser
1410
+ * bundle, vitest (jsdom), and future shells.
1411
+ *
1412
+ * Design intent:
1413
+ * - `compareVersions` / `needsUpdate` are pure, side-effect-free functions that
1414
+ * follow npm semver precedence for the shapes this plugin actually publishes
1415
+ * (`x.y.z` plus optional `-rc.N` / `-beta.N` prereleases). Anything else is
1416
+ * an explicit error rather than a silent mis-comparison.
1417
+ * - `fetchLatestVersion` is the only I/O: it reads the `latest` dist-tag from
1418
+ * npmmirror's cheap dist-tags endpoint first (it CORS-echoes this page's
1419
+ * Origin), then falls back to the full npm packument (ACAO: *). Every
1420
+ * request carries an internal 8s timeout combined with the caller's signal —
1421
+ * whichever aborts first wins — and every failure degrades to a structured
1422
+ * `{ ok: false }` result, never a throw.
1423
+ * - `SUPPORTED_HOST_LINES` is a SELF-DECLARED compatibility list, not a probe:
1424
+ * the harness reports no trustworthy host version in the browser
1425
+ * (`host.describe().version` is a stub), so the plugin declares which npm
1426
+ * `latest` host-version lines it supports and the UI renders that as-is.
1427
+ */
1428
+ /** Host-version lines this plugin declares support for (npm official latest
1429
+ * line; bump it as the peer/dependency ranges move). */
1430
+ const SUPPORTED_HOST_LINES = ["0.1.1-rc.2"];
1431
+ /** Internal per-request timeout for the network attempts. */
1432
+ const REQUEST_TIMEOUT_MS = 8e3;
1433
+ /** npmmirror dist-tags endpoint: lean JSON (`{"latest":"0.6.0", ...}`),
1434
+ * echoes this page's Origin in `Access-Control-Allow-Origin`. */
1435
+ const NPMIRROR_DIST_TAGS_URL = "https://registry.npmmirror.com/-/package/dsh-milestone/dist-tags";
1436
+ /** npm full packument; `dist-tags.latest` sits at the JSON root, and the
1437
+ * endpoint sends `Access-Control-Allow-Origin: *`. */
1438
+ const NPM_PACKUMENT_URL = "https://registry.npmjs.org/dsh-milestone";
1439
+ /** Numeric identifier test shared by core and prerelease segments. */
1440
+ const NUMERIC_RE = /^\d+$/;
1441
+ /**
1442
+ * Parse a version string into comparable parts.
1443
+ * Accepts `major[.minor[.patch]][-prerelease][+build]`; missing core segments
1444
+ * pad with 0 and `+build` metadata is ignored (per semver precedence).
1445
+ * @throws {Error} with the offending input when the shape is not parseable.
1446
+ */
1447
+ function parseVersion(input) {
1448
+ if (typeof input !== "string" || input.trim() === "") throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (expected "x.y.z" with optional "-pre" suffix)`);
1449
+ const s = input.trim();
1450
+ const plus = s.indexOf("+");
1451
+ const withoutBuild = plus === -1 ? s : s.slice(0, plus);
1452
+ const dash = withoutBuild.indexOf("-");
1453
+ const corePart = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
1454
+ const prePart = dash === -1 ? void 0 : withoutBuild.slice(dash + 1);
1455
+ const segments = corePart.split(".");
1456
+ 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)`);
1457
+ const core = segments.map(Number);
1458
+ let pre = [];
1459
+ if (prePart !== void 0) {
1460
+ if (prePart === "" || !prePart.split(".").every((id) => /^[0-9A-Za-z-]+$/.test(id))) throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (bad prerelease suffix)`);
1461
+ pre = prePart.split(".");
1462
+ }
1463
+ return {
1464
+ core,
1465
+ pre
1466
+ };
1467
+ }
1468
+ /**
1469
+ * Compare two version strings per npm semver precedence.
1470
+ * @param a - first version (`x.y.z` with optional `-pre` suffix; build
1471
+ * metadata ignored; missing core segments pad with 0).
1472
+ * @param b - second version, same grammar.
1473
+ * @returns -1 when `a < b`, 0 when equal, 1 when `a > b`. Prereleases sort
1474
+ * below their same-number release; prerelease identifiers compare numerically
1475
+ * when both are numeric, lexically when both are alphanumeric, and numeric
1476
+ * identifiers always sort before alphanumeric ones.
1477
+ * @throws {Error} for inputs that do not match the grammar.
1478
+ */
1479
+ function compareVersions(a, b) {
1480
+ const pa = parseVersion(a);
1481
+ const pb = parseVersion(b);
1482
+ for (let i = 0; i < 3; i++) {
1483
+ const x = pa.core[i] ?? 0;
1484
+ const y = pb.core[i] ?? 0;
1485
+ if (x < y) return -1;
1486
+ if (x > y) return 1;
1487
+ }
1488
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
1489
+ if (pa.pre.length === 0) return 1;
1490
+ if (pb.pre.length === 0) return -1;
1491
+ const len = Math.max(pa.pre.length, pb.pre.length);
1492
+ for (let i = 0; i < len; i++) {
1493
+ const x = pa.pre[i];
1494
+ const y = pb.pre[i];
1495
+ if (x === void 0) return -1;
1496
+ if (y === void 0) return 1;
1497
+ const xNum = NUMERIC_RE.test(x);
1498
+ const yNum = NUMERIC_RE.test(y);
1499
+ if (xNum && yNum) {
1500
+ if (x !== y) return Number(x) < Number(y) ? -1 : 1;
1501
+ } else if (xNum) return -1;
1502
+ else if (yNum) return 1;
1503
+ else if (x !== y) return x < y ? -1 : 1;
1504
+ }
1505
+ return 0;
1506
+ }
1507
+ /**
1508
+ * Whether the installed plugin should offer an update.
1509
+ * @param current - installed version.
1510
+ * @param latest - newest published version.
1511
+ * @returns true only when `latest` is strictly greater than `current`
1512
+ * (identical versions, or a newer installed version, return false).
1513
+ * @throws {Error} when either input is not a parseable version.
1514
+ */
1515
+ function needsUpdate(current, latest) {
1516
+ return compareVersions(current, latest) < 0;
1517
+ }
1518
+ /**
1519
+ * Build a request signal: a fresh AbortController aborted by EITHER the
1520
+ * caller's external signal (which wins when it fires first) OR an internal
1521
+ * timeout. Uses `AbortSignal.timeout` when the environment provides it and
1522
+ * falls back to a manual `setTimeout` + abort otherwise (jsdom older
1523
+ * versions expose no `AbortSignal.timeout`).
1524
+ * @param external - caller signal; when already aborted the request fires
1525
+ * immediately with an aborted signal.
1526
+ * @param timeoutMs - internal timeout in ms.
1527
+ * @returns the combined signal plus a cleanup that detaches all listeners
1528
+ * (must be called so no listener outlives the request).
1529
+ */
1530
+ function createRequestSignal(external, timeoutMs) {
1531
+ const controller = new AbortController();
1532
+ const abort = () => controller.abort();
1533
+ const detach = [];
1534
+ if (external) if (external.aborted) controller.abort();
1535
+ else {
1536
+ external.addEventListener("abort", abort, { once: true });
1537
+ detach.push(() => external.removeEventListener("abort", abort));
1538
+ }
1539
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
1540
+ const t = AbortSignal.timeout(timeoutMs);
1541
+ t.addEventListener("abort", abort, { once: true });
1542
+ detach.push(() => t.removeEventListener("abort", abort));
1543
+ } else {
1544
+ const timer = setTimeout(abort, timeoutMs);
1545
+ detach.push(() => clearTimeout(timer));
1546
+ }
1547
+ return {
1548
+ signal: controller.signal,
1549
+ cleanup: () => {
1550
+ for (const fn of detach) fn();
1551
+ detach.length = 0;
1552
+ }
1553
+ };
1554
+ }
1555
+ /** Extract `latest` from the npmmirror dist-tags JSON root. */
1556
+ function extractFromDistTags(data) {
1557
+ if (data === null || typeof data !== "object") return null;
1558
+ const latest = data.latest;
1559
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
1560
+ }
1561
+ /** Extract `latest` from the npm packument's root `dist-tags` object. */
1562
+ function extractFromPackument(data) {
1563
+ if (data === null || typeof data !== "object") return null;
1564
+ const distTags = data["dist-tags"];
1565
+ if (distTags === null || typeof distTags !== "object") return null;
1566
+ const latest = distTags.latest;
1567
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
1568
+ }
1569
+ /** Human-readable failure detail for one endpoint attempt. */
1570
+ function describeError(source, cause) {
1571
+ const name = cause instanceof Error ? cause.name : "";
1572
+ const message = cause instanceof Error ? cause.message : String(cause);
1573
+ if (name === "AbortError") return `${source}: aborted (${message})`;
1574
+ return `${source}: ${message}`;
1575
+ }
1576
+ /**
1577
+ * One attempt at fetching the `latest` dist-tag from an endpoint.
1578
+ * @returns ok with the tag, or ok:false with a per-source error description.
1579
+ */
1580
+ async function tryFetchLatest(url, extract, source, external) {
1581
+ try {
1582
+ const { signal, cleanup } = createRequestSignal(external, REQUEST_TIMEOUT_MS);
1583
+ try {
1584
+ const res = await fetch(url, { signal });
1585
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1586
+ const latest = extract(await res.json());
1587
+ if (latest === null) throw new Error("unexpected response shape (no \"latest\" dist-tag)");
1588
+ return {
1589
+ ok: true,
1590
+ latest,
1591
+ source
1592
+ };
1593
+ } finally {
1594
+ cleanup();
1595
+ }
1596
+ } catch (cause) {
1597
+ return {
1598
+ ok: false,
1599
+ error: describeError(source, cause)
1600
+ };
1601
+ }
1602
+ }
1603
+ /**
1604
+ * Query npm for the newest published version of `dsh-milestone`.
1605
+ *
1606
+ * Strategy: npmmirror's dist-tags endpoint first (lightweight and CORS-open
1607
+ * to this Origin); if that fails or its shape is wrong, the full npm
1608
+ * packument fallback (`dist-tags.latest` at the JSON root). Each request
1609
+ * aborts after 8s via `AbortSignal.timeout` (manual timer fallback when the
1610
+ * environment lacks it) OR immediately when the passed-in signal aborts.
1611
+ * @param signal - optional caller abort signal; aborts the in-flight attempt
1612
+ * with priority over the internal timeout.
1613
+ * @returns the latest version and which registry answered, or a structured
1614
+ * error when both endpoints fail. Never throws.
1615
+ */
1616
+ async function fetchLatestVersion(signal) {
1617
+ const viaMirror = await tryFetchLatest(NPMIRROR_DIST_TAGS_URL, extractFromDistTags, "npmmirror", signal);
1618
+ if (viaMirror.ok) return viaMirror;
1619
+ const viaNpm = await tryFetchLatest(NPM_PACKUMENT_URL, extractFromPackument, "npm", signal);
1620
+ if (viaNpm.ok) return viaNpm;
1621
+ return {
1622
+ ok: false,
1623
+ error: `${viaMirror.error}; ${viaNpm.error}`
1624
+ };
1625
+ }
1626
+ /** localStorage key holding the last successful update check. */
1627
+ const UPDATE_CACHE_KEY = "dsh-milestone.update-cache";
1628
+ /**
1629
+ * Pure parse + freshness check of a stored blob: `null` for `null`/invalid
1630
+ * JSON, a wrong shape, or an entry older than {@link UPDATE_CACHE_TTL_MS}.
1631
+ * A `checkedAt` in the future (clock skew) is treated as fresh — it decays
1632
+ * naturally once wall time catches up. Never throws.
1633
+ * @param raw - the raw `localStorage` value (or null when absent).
1634
+ * @param now - wall-clock epoch ms to judge freshness against (injectable for
1635
+ * tests; defaults to `Date.now()`).
1636
+ */
1637
+ function parseUpdateCache(raw, now = Date.now()) {
1638
+ if (raw === null) return null;
1639
+ let parsed;
1640
+ try {
1641
+ parsed = JSON.parse(raw);
1642
+ } catch {
1643
+ return null;
1644
+ }
1645
+ if (typeof parsed !== "object" || parsed === null) return null;
1646
+ const { latest, source, checkedAt } = parsed;
1647
+ if (typeof latest !== "string" || latest === "") return null;
1648
+ if (source !== "npmmirror" && source !== "npm") return null;
1649
+ if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt)) return null;
1650
+ if (now - checkedAt >= 216e5) return null;
1651
+ return {
1652
+ latest,
1653
+ source,
1654
+ checkedAt
1655
+ };
1656
+ }
1657
+ /**
1658
+ * Read + parse the persisted cache entry from localStorage. Any storage
1659
+ * failure degrades to `null` (cache miss) — the update check is best-effort.
1660
+ * @param now - wall-clock epoch ms for freshness (see {@link parseUpdateCache}).
1661
+ */
1662
+ function readUpdateCache(now = Date.now()) {
1663
+ try {
1664
+ return parseUpdateCache(localStorage.getItem(UPDATE_CACHE_KEY), now);
1665
+ } catch {
1666
+ return null;
1667
+ }
1668
+ }
1669
+ /**
1670
+ * Persist a successful check result (the caller supplies `checkedAt`, usually
1671
+ * `Date.now()`). Silently ignores storage failures — the cache is an
1672
+ * optimization, never a hard dependency.
1673
+ */
1674
+ function writeUpdateCache(entry) {
1675
+ try {
1676
+ localStorage.setItem(UPDATE_CACHE_KEY, JSON.stringify(entry));
1677
+ } catch {}
1678
+ }
1679
+ /**
1680
+ * The cache-aware entry point for the UI: reuse an unexpired cached result
1681
+ * when one exists, otherwise query npm (npmmirror → packument fallback) and
1682
+ * persist a successful result for the next {@link UPDATE_CACHE_TTL_MS}.
1683
+ * Never throws; failures return the same structured `{ ok: false }` result as
1684
+ * {@link fetchLatestVersion}.
1685
+ * @param signal - optional caller abort signal, forwarded to the network
1686
+ * attempt only (a cache hit needs no signal).
1687
+ */
1688
+ async function loadCachedLatest(signal) {
1689
+ const cached = readUpdateCache();
1690
+ if (cached !== null) return {
1691
+ ok: true,
1692
+ latest: cached.latest,
1693
+ source: cached.source
1694
+ };
1695
+ const fresh = await fetchLatestVersion(signal);
1696
+ if (fresh.ok) writeUpdateCache({
1697
+ latest: fresh.latest,
1698
+ source: fresh.source,
1699
+ checkedAt: Date.now()
1700
+ });
1701
+ return fresh;
1702
+ }
1703
+ //#endregion
1704
+ //#region src/client/version-meta.ts
1705
+ /**
1706
+ * Installed plugin version. Injected at build time as
1707
+ * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
1708
+ */
1709
+ const PLUGIN_VERSION = "0.6.1";
1710
+ //#endregion
841
1711
  //#region src/client/MilestoneRail.tsx
842
1712
  /**
843
1713
  * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
@@ -890,15 +1760,58 @@ window.__ModuleLoader__.load({
890
1760
  70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
891
1761
  100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
892
1762
  }`;
1763
+ /**
1764
+ * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
1765
+ * conversation reads cleaner. The rule targets the stable, un-hashed
1766
+ * `data-variant="think"` attribute on the thinking-block ROOT (the harness
1767
+ * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
1768
+ * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
1769
+ * opening it, `[data-open]`) restores full opacity. Kept in an inline
1770
+ * <style> so the plugin stays zero-asset — same pattern as BADGE_PULSE_CSS.
1771
+ */
1772
+ const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
1773
+ [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
893
1774
  /** Visual dot diameter (px). */
894
- const DOT_SIZE = 12;
1775
+ const DOT_SIZE = 14;
895
1776
  /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
896
- const DOT_HIT = 22;
1777
+ const DOT_HIT = 28;
897
1778
  /** Vertical gap between dot hit areas (px) — fixed pitch, never scaled. */
898
- const DOT_GAP = 12;
1779
+ const DOT_GAP = 14;
899
1780
  /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
900
1781
  const RAIL_INSET = 14;
901
1782
  /**
1783
+ * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
1784
+ * deep-link attempt — the harness scrolls the conversation to the bottom on
1785
+ * load, so the deep link must land AFTER the view mounts.
1786
+ */
1787
+ const DEEP_LINK_INITIAL_DELAY = 100;
1788
+ /** P3: interval between DOM-row polls while waiting for the target to render. */
1789
+ const DEEP_LINK_POLL_DELAY = 150;
1790
+ /** P3: polls before falling back to a single `loadOlder` fetch. */
1791
+ const DEEP_LINK_MAX_POLLS = 5;
1792
+ /** P3: bounded polls after `loadOlder`, then the deep link gives up silently. */
1793
+ const DEEP_LINK_MAX_RETRY_POLLS = 5;
1794
+ /** B4 update-check: mount-time silent check delay (ms) — give the harness
1795
+ * time to settle before hitting the registry. */
1796
+ const UPDATE_CHECK_MOUNT_DELAY = 1500;
1797
+ /** Initial state: no check has completed yet, nothing to show. */
1798
+ const NO_UPDATE_CHECK = {
1799
+ phase: "idle",
1800
+ latest: null,
1801
+ source: null,
1802
+ error: null,
1803
+ available: false
1804
+ };
1805
+ /**
1806
+ * B4 display label for one supported host line: strips the fixed
1807
+ * `x.y.z` prefix and appends "line" — `0.1.1-rc.2` → `rc.2 line`,
1808
+ * `0.1.1` → `0.1.1 line`. Pure presentation metadata.
1809
+ */
1810
+ function hostLineLabel(line) {
1811
+ const suffix = line.replace(/^\d+\.\d+\.\d+-?/, "");
1812
+ return suffix === "" ? `${line} line` : `${suffix} line`;
1813
+ }
1814
+ /**
902
1815
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
903
1816
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
904
1817
  */
@@ -930,7 +1843,10 @@ window.__ModuleLoader__.load({
930
1843
  * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
931
1844
  * outside the slot machinery).
932
1845
  */
933
- function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, t = (key) => key }) {
1846
+ function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1847
+ items: [],
1848
+ hasMore: false
1849
+ }), openSession = () => {}, t = (key) => key }) {
934
1850
  const order = useSession((s) => s.chat.order);
935
1851
  const nodes = useSession((s) => s.chat.nodes);
936
1852
  const locations = useSession((s) => s.chat.locations);
@@ -981,12 +1897,33 @@ window.__ModuleLoader__.load({
981
1897
  panelOpen: false
982
1898
  });
983
1899
  const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
1900
+ const [focusActive, setFocusActive] = (0, react.useState)(false);
1901
+ const [listOpen, setListOpen] = (0, react.useState)(false);
1902
+ const [crossOpen, setCrossOpen] = (0, react.useState)(false);
984
1903
  const [copiedKey, setCopiedKey] = (0, react.useState)(null);
985
1904
  const [forkedKey, setForkedKey] = (0, react.useState)(null);
986
1905
  const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
987
1906
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
988
1907
  const listRef = (0, react.useRef)(null);
989
1908
  const currentKey = useCurrentAnchor(order);
1909
+ /**
1910
+ * P3: jump to the chat row with the given node key — smooth-scroll it into
1911
+ * view and write the position back into the URL hash (`#msg=<key>`) so
1912
+ * refresh and share preserve it. `history.replaceState` (not a
1913
+ * `location.hash` assignment) keeps the history stack clean, and it never
1914
+ * fires `hashchange`, so the deep-link listeners below never echo the
1915
+ * rail's own updates. No-op when the row is not (yet) rendered — the
1916
+ * deep-link mount retry and the load-older flow cover that case.
1917
+ */
1918
+ const jump = (key) => {
1919
+ const row = findRow(key);
1920
+ if (row === null) return;
1921
+ row.scrollIntoView({
1922
+ behavior: "smooth",
1923
+ block: "start"
1924
+ });
1925
+ history.replaceState(null, "", buildMessageHash(key));
1926
+ };
990
1927
  const displayMarks = (0, react.useMemo)(() => {
991
1928
  if (!bookmarksOnly) return marks;
992
1929
  return filterByBookmarks(marks, bookmarkedKeys).visible.map((i) => marks[i]);
@@ -1041,13 +1978,141 @@ window.__ModuleLoader__.load({
1041
1978
  (0, react.useLayoutEffect)(() => {
1042
1979
  setFocusIndex((f) => clampIndex(f, render.items.length));
1043
1980
  }, [render.items.length]);
1044
- if (railBox === null || marks.length < MIN_MARKS) return null;
1045
- const jump = (key) => {
1046
- findRow(key)?.scrollIntoView({
1047
- behavior: "smooth",
1048
- block: "start"
1981
+ (0, react.useEffect)(() => {
1982
+ if (!listOpen && !crossOpen) return;
1983
+ const onKey = (e) => {
1984
+ if (e.key !== "Escape") return;
1985
+ setListOpen(false);
1986
+ setCrossOpen(false);
1987
+ };
1988
+ window.addEventListener("keydown", onKey);
1989
+ return () => window.removeEventListener("keydown", onKey);
1990
+ }, [listOpen, crossOpen]);
1991
+ const [toolbarExpanded, setToolbarExpanded] = (0, react.useState)(false);
1992
+ const [settingsOpen, setSettingsOpen] = (0, react.useState)(false);
1993
+ const [pinned, setPinned] = (0, react.useState)(() => loadPrefs());
1994
+ const settingsRef = (0, react.useRef)(null);
1995
+ const settingsBtnRef = (0, react.useRef)(null);
1996
+ const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
1997
+ const [updateCheck, setUpdateCheck] = (0, react.useState)(NO_UPDATE_CHECK);
1998
+ const updatePanelRef = (0, react.useRef)(null);
1999
+ const updateBtnRef = (0, react.useRef)(null);
2000
+ /**
2001
+ * B1 settings menu: outside-pointerdown dismisses the menu (shared
2002
+ * useOutsideDismiss contract) with focus returning to the gear afterwards.
2003
+ * The gear's own click keeps its flip semantics through a
2004
+ * `[data-toolbar-settings]` exclusion — pointerdown on an armed gear must
2005
+ * not double-close.
2006
+ */
2007
+ useOutsideDismiss(settingsRef, settingsOpen, () => {
2008
+ setSettingsOpen(false);
2009
+ settingsBtnRef.current?.focus();
2010
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-toolbar-settings]") });
2011
+ (0, react.useEffect)(() => {
2012
+ if (!settingsOpen) return;
2013
+ const onKey = (e) => {
2014
+ if (e.key !== "Escape") return;
2015
+ setSettingsOpen(false);
2016
+ settingsBtnRef.current?.focus();
2017
+ };
2018
+ window.addEventListener("keydown", onKey);
2019
+ return () => window.removeEventListener("keydown", onKey);
2020
+ }, [settingsOpen]);
2021
+ /**
2022
+ * B4: run one update check. Cache-aware (`loadCachedLatest` reuses an
2023
+ * unexpired cached result without any network traffic) and never throws:
2024
+ * a network failure lands in the `failed` phase with the structured error,
2025
+ * and an unparseable `latest` (registry anomaly) is treated as "not
2026
+ * available" rather than crashing the panel.
2027
+ */
2028
+ const runUpdateCheck = () => {
2029
+ setUpdateCheck((prev) => ({
2030
+ ...prev,
2031
+ phase: "checking"
2032
+ }));
2033
+ loadCachedLatest().then((result) => {
2034
+ if (result.ok) {
2035
+ let available = false;
2036
+ try {
2037
+ available = needsUpdate(PLUGIN_VERSION, result.latest);
2038
+ } catch {
2039
+ available = false;
2040
+ }
2041
+ setUpdateCheck({
2042
+ phase: "ok",
2043
+ latest: result.latest,
2044
+ source: result.source,
2045
+ error: null,
2046
+ available
2047
+ });
2048
+ } else setUpdateCheck((prev) => ({
2049
+ ...prev,
2050
+ phase: "failed",
2051
+ error: result.error
2052
+ }));
1049
2053
  });
1050
2054
  };
2055
+ (0, react.useEffect)(() => {
2056
+ const timer = window.setTimeout(runUpdateCheck, UPDATE_CHECK_MOUNT_DELAY);
2057
+ return () => window.clearTimeout(timer);
2058
+ }, []);
2059
+ useOutsideDismiss(updatePanelRef, updateOpen, () => {
2060
+ setUpdateOpen(false);
2061
+ updateBtnRef.current?.focus();
2062
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-update-check]") });
2063
+ (0, react.useEffect)(() => {
2064
+ if (!updateOpen) return;
2065
+ const onKey = (e) => {
2066
+ if (e.key !== "Escape") return;
2067
+ setUpdateOpen(false);
2068
+ updateBtnRef.current?.focus();
2069
+ };
2070
+ window.addEventListener("keydown", onKey);
2071
+ return () => window.removeEventListener("keydown", onKey);
2072
+ }, [updateOpen]);
2073
+ const marksRef = (0, react.useRef)(marks);
2074
+ (0, react.useEffect)(() => {
2075
+ marksRef.current = marks;
2076
+ });
2077
+ (0, react.useEffect)(() => {
2078
+ const key = parseDeepLinkHash(window.location.hash);
2079
+ if (key === null) return;
2080
+ let cancelled = false;
2081
+ let timer;
2082
+ const attempt = (pollsLeft, canLoadOlder) => {
2083
+ if (cancelled) return;
2084
+ if (findRow(key) !== null) {
2085
+ jump(key);
2086
+ return;
2087
+ }
2088
+ if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
2089
+ if (pollsLeft > 0) {
2090
+ timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
2091
+ return;
2092
+ }
2093
+ if (canLoadOlder) {
2094
+ loadOlder().then(() => {
2095
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_RETRY_POLLS, false), DEEP_LINK_POLL_DELAY);
2096
+ }, () => {});
2097
+ return;
2098
+ }
2099
+ };
2100
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_POLLS, true), DEEP_LINK_INITIAL_DELAY);
2101
+ return () => {
2102
+ cancelled = true;
2103
+ if (timer !== void 0) window.clearTimeout(timer);
2104
+ };
2105
+ }, []);
2106
+ (0, react.useEffect)(() => {
2107
+ const onHashChange = () => {
2108
+ const key = parseDeepLinkHash(window.location.hash);
2109
+ if (key === null) return;
2110
+ if (marksRef.current.some((m) => m.key === key)) jump(key);
2111
+ };
2112
+ window.addEventListener("hashchange", onHashChange);
2113
+ return () => window.removeEventListener("hashchange", onHashChange);
2114
+ }, []);
2115
+ if (railBox === null || marks.length < MIN_MARKS) return null;
1051
2116
  const updateQuery = (query) => {
1052
2117
  setSearch({
1053
2118
  query,
@@ -1083,6 +2148,276 @@ window.__ModuleLoader__.load({
1083
2148
  if (e.key === "Enter") advanceMatch();
1084
2149
  if (e.key === "Escape") closeSearch();
1085
2150
  };
2151
+ /** B1: flip one feature's pin — state and the persisted blob update together. */
2152
+ const onTogglePin = (id) => {
2153
+ setPinned((prev) => {
2154
+ const next = togglePin(prev, id);
2155
+ savePrefs(next);
2156
+ return next;
2157
+ });
2158
+ };
2159
+ /** B1: reset every pin (everything folds away) and persist the empty set. */
2160
+ const onResetPins = () => {
2161
+ setPinned([]);
2162
+ savePrefs([]);
2163
+ };
2164
+ /** B1: a feature renders while the toolbar is EXPANDED or while it is pinned. */
2165
+ const featureVisible = (id) => toolbarExpanded || pinned.includes(id);
2166
+ /**
2167
+ * B1: the data-driven feature registry. Each entry's render is the feature's
2168
+ * UNCHANGED rail-top chrome (data attributes / aria semantics preserved),
2169
+ * moved verbatim from the previous static button block; `search` is the
2170
+ * whole RailSearchUi (toggle + panel) so its lifecycle stays component-local
2171
+ * in the rail (search state lives in the rail and survives unmount).
2172
+ * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式).
2173
+ *
2174
+ * EXTENSION POINT: push a new feature here (+ its id in toolbar-prefs.ts's
2175
+ * TOOLBAR_PIN_IDS and its locale keys) and pinning/settings/expand all
2176
+ * follow automatically — see the ToolbarFeatureDef doc above.
2177
+ */
2178
+ const toolbarFeatures = [
2179
+ {
2180
+ id: "search",
2181
+ labelKey: "search.label",
2182
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2183
+ panelTop: railBox.top,
2184
+ panelRight: railBox.right + DOT_HIT + 8,
2185
+ query: search.query,
2186
+ panelOpen: search.panelOpen,
2187
+ matches: matches.length,
2188
+ total: displayMarks.length,
2189
+ onToggle: () => setSearch((s) => ({
2190
+ ...s,
2191
+ panelOpen: !s.panelOpen
2192
+ })),
2193
+ onQueryChange: updateQuery,
2194
+ onSearchKeyDown,
2195
+ onClear: clearSearch,
2196
+ t
2197
+ })
2198
+ },
2199
+ {
2200
+ id: "list",
2201
+ labelKey: "list.label",
2202
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2203
+ type: "button",
2204
+ "data-list-toggle": true,
2205
+ "aria-label": listOpen ? t("list.close") : t("list.open"),
2206
+ title: listOpen ? t("list.close") : t("list.open"),
2207
+ "aria-pressed": listOpen,
2208
+ onClick: () => setListOpen((v) => !v),
2209
+ style: {
2210
+ width: DOT_HIT,
2211
+ height: DOT_HIT,
2212
+ flexShrink: 0,
2213
+ display: "flex",
2214
+ alignItems: "center",
2215
+ justifyContent: "center",
2216
+ background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2217
+ border: "none",
2218
+ padding: 0,
2219
+ cursor: "pointer",
2220
+ color: listOpen ? "#9db8ff" : "#8b96ab"
2221
+ },
2222
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2223
+ width: "16",
2224
+ height: "16",
2225
+ viewBox: "0 0 24 24",
2226
+ fill: "none",
2227
+ stroke: "currentColor",
2228
+ strokeWidth: "2.5",
2229
+ strokeLinecap: "round",
2230
+ "aria-hidden": "true",
2231
+ children: [
2232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
2234
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
2235
+ ]
2236
+ })
2237
+ })
2238
+ },
2239
+ {
2240
+ id: "sessionSearch",
2241
+ labelKey: "search.cross",
2242
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2243
+ type: "button",
2244
+ "data-session-search-toggle": true,
2245
+ "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
2246
+ title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
2247
+ "aria-pressed": crossOpen,
2248
+ onClick: () => setCrossOpen((v) => !v),
2249
+ style: {
2250
+ width: DOT_HIT,
2251
+ height: DOT_HIT,
2252
+ flexShrink: 0,
2253
+ display: "flex",
2254
+ alignItems: "center",
2255
+ justifyContent: "center",
2256
+ background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2257
+ border: "none",
2258
+ padding: 0,
2259
+ cursor: "pointer",
2260
+ color: crossOpen ? "#9db8ff" : "#8b96ab"
2261
+ },
2262
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2263
+ width: "16",
2264
+ height: "16",
2265
+ viewBox: "0 0 24 24",
2266
+ fill: "none",
2267
+ stroke: "currentColor",
2268
+ strokeWidth: "2",
2269
+ strokeLinecap: "round",
2270
+ strokeLinejoin: "round",
2271
+ "aria-hidden": "true",
2272
+ children: [
2273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
2274
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
2275
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
2276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2277
+ cx: "17",
2278
+ cy: "7",
2279
+ r: "3.5"
2280
+ }),
2281
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
2282
+ ]
2283
+ })
2284
+ })
2285
+ },
2286
+ {
2287
+ id: "bookmarks",
2288
+ labelKey: "bookmark.filter",
2289
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2290
+ type: "button",
2291
+ "data-bookmarks-toggle": true,
2292
+ "aria-label": t("bookmark.filter"),
2293
+ "aria-pressed": bookmarksOnly,
2294
+ "data-active": bookmarksOnly ? "true" : void 0,
2295
+ onClick: () => setBookmarksOnly((v) => !v),
2296
+ style: {
2297
+ width: DOT_HIT,
2298
+ height: DOT_HIT,
2299
+ flexShrink: 0,
2300
+ display: "flex",
2301
+ alignItems: "center",
2302
+ justifyContent: "center",
2303
+ background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
2304
+ border: "none",
2305
+ padding: 0,
2306
+ cursor: "pointer",
2307
+ color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
2308
+ },
2309
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2310
+ width: "16",
2311
+ height: "16",
2312
+ viewBox: "0 0 24 24",
2313
+ fill: bookmarksOnly ? "currentColor" : "none",
2314
+ stroke: "currentColor",
2315
+ strokeWidth: "2",
2316
+ strokeLinejoin: "round",
2317
+ "aria-hidden": "true",
2318
+ 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" })
2319
+ })
2320
+ })
2321
+ },
2322
+ {
2323
+ id: "focus",
2324
+ labelKey: "focus.on",
2325
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2326
+ type: "button",
2327
+ "data-focus-toggle": true,
2328
+ "aria-label": focusActive ? t("focus.off") : t("focus.on"),
2329
+ title: focusActive ? t("focus.off") : t("focus.on"),
2330
+ "aria-pressed": focusActive,
2331
+ onClick: () => setFocusActive((v) => !v),
2332
+ style: {
2333
+ width: DOT_HIT,
2334
+ height: DOT_HIT,
2335
+ flexShrink: 0,
2336
+ display: "flex",
2337
+ alignItems: "center",
2338
+ justifyContent: "center",
2339
+ background: focusActive ? "rgba(126, 226, 168, 0.14)" : "transparent",
2340
+ border: "none",
2341
+ padding: 0,
2342
+ cursor: "pointer",
2343
+ color: focusActive ? "#7ee2a8" : "#8b96ab"
2344
+ },
2345
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2346
+ width: "16",
2347
+ height: "16",
2348
+ viewBox: "0 0 24 24",
2349
+ fill: "none",
2350
+ stroke: "currentColor",
2351
+ strokeWidth: "2",
2352
+ strokeLinecap: "round",
2353
+ strokeLinejoin: "round",
2354
+ "aria-hidden": "true",
2355
+ 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", {
2356
+ cx: "12",
2357
+ cy: "12",
2358
+ r: "3"
2359
+ })]
2360
+ })
2361
+ })
2362
+ },
2363
+ {
2364
+ id: "updateCheck",
2365
+ labelKey: "update.check",
2366
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2367
+ type: "button",
2368
+ ref: updateBtnRef,
2369
+ "data-update-check": true,
2370
+ "aria-expanded": updateOpen,
2371
+ "aria-label": t("update.check"),
2372
+ title: t("update.check"),
2373
+ onClick: () => setUpdateOpen((v) => !v),
2374
+ style: {
2375
+ position: "relative",
2376
+ width: DOT_HIT,
2377
+ height: DOT_HIT,
2378
+ flexShrink: 0,
2379
+ display: "flex",
2380
+ alignItems: "center",
2381
+ justifyContent: "center",
2382
+ background: updateOpen || updateCheck.available ? "rgba(245, 197, 66, 0.14)" : "transparent",
2383
+ border: "none",
2384
+ padding: 0,
2385
+ cursor: "pointer",
2386
+ color: updateCheck.available ? "#f5c542" : "#8b96ab"
2387
+ },
2388
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2389
+ width: "16",
2390
+ height: "16",
2391
+ viewBox: "0 0 24 24",
2392
+ fill: "none",
2393
+ stroke: "currentColor",
2394
+ strokeWidth: "2",
2395
+ strokeLinecap: "round",
2396
+ strokeLinejoin: "round",
2397
+ "aria-hidden": "true",
2398
+ children: [
2399
+ /* @__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" }),
2400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 3v5h-5" }),
2401
+ /* @__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" }),
2402
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 16H3v5" })
2403
+ ]
2404
+ }), updateCheck.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2405
+ "data-update-available": true,
2406
+ style: {
2407
+ position: "absolute",
2408
+ top: -2,
2409
+ right: -2,
2410
+ width: 8,
2411
+ height: 8,
2412
+ borderRadius: "50%",
2413
+ background: "#f5c542",
2414
+ border: "2px solid rgba(20, 24, 32, 0.95)",
2415
+ pointerEvents: "none"
2416
+ }
2417
+ })]
2418
+ })
2419
+ }
2420
+ ];
1086
2421
  /** Focus the dot at `index` (no-op while the list is unmounted). */
1087
2422
  const focusDotAt = (index) => {
1088
2423
  listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
@@ -1208,11 +2543,15 @@ window.__ModuleLoader__.load({
1208
2543
  pointerEvents: "auto",
1209
2544
  zIndex: 100,
1210
2545
  display: "flex",
1211
- flexDirection: "column"
2546
+ flexDirection: "column",
2547
+ gap: 6,
2548
+ paddingTop: 6
1212
2549
  },
1213
2550
  "aria-label": t("rail.label"),
2551
+ "data-focus-active": focusActive ? "true" : void 0,
1214
2552
  children: [
1215
2553
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
2554
+ focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
1216
2555
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1217
2556
  type: "button",
1218
2557
  "data-load-older": true,
@@ -1235,7 +2574,7 @@ window.__ModuleLoader__.load({
1235
2574
  padding: 0,
1236
2575
  cursor: loadingOlder ? "default" : "pointer",
1237
2576
  color: loadingOlder ? "#5a6375" : "#8b96ab",
1238
- fontSize: 11,
2577
+ fontSize: 13,
1239
2578
  lineHeight: 1,
1240
2579
  letterSpacing: 1
1241
2580
  },
@@ -1243,11 +2582,11 @@ window.__ModuleLoader__.load({
1243
2582
  }),
1244
2583
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1245
2584
  type: "button",
1246
- "data-bookmarks-toggle": true,
1247
- "aria-label": t("bookmark.filter"),
1248
- "aria-pressed": bookmarksOnly,
1249
- "data-active": bookmarksOnly ? "true" : void 0,
1250
- onClick: () => setBookmarksOnly((v) => !v),
2585
+ "data-toolbar-expand": true,
2586
+ "aria-expanded": toolbarExpanded,
2587
+ "aria-label": toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2588
+ title: toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2589
+ onClick: () => setToolbarExpanded((v) => !v),
1251
2590
  style: {
1252
2591
  width: DOT_HIT,
1253
2592
  height: DOT_HIT,
@@ -1255,38 +2594,390 @@ window.__ModuleLoader__.load({
1255
2594
  display: "flex",
1256
2595
  alignItems: "center",
1257
2596
  justifyContent: "center",
1258
- background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
2597
+ background: "transparent",
1259
2598
  border: "none",
1260
2599
  padding: 0,
1261
2600
  cursor: "pointer",
1262
- color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
2601
+ color: "#8b96ab"
1263
2602
  },
1264
2603
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1265
- width: "13",
1266
- height: "13",
2604
+ width: "16",
2605
+ height: "16",
1267
2606
  viewBox: "0 0 24 24",
1268
- fill: bookmarksOnly ? "currentColor" : "none",
2607
+ fill: "none",
2608
+ stroke: "currentColor",
2609
+ strokeWidth: "2.5",
2610
+ strokeLinecap: "round",
2611
+ strokeLinejoin: "round",
2612
+ "aria-hidden": "true",
2613
+ 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" })
2614
+ })
2615
+ }),
2616
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2617
+ type: "button",
2618
+ ref: settingsBtnRef,
2619
+ "data-toolbar-settings": true,
2620
+ "aria-expanded": settingsOpen,
2621
+ "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2622
+ title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2623
+ onClick: () => setSettingsOpen((v) => !v),
2624
+ style: {
2625
+ width: DOT_HIT,
2626
+ height: DOT_HIT,
2627
+ flexShrink: 0,
2628
+ display: "flex",
2629
+ alignItems: "center",
2630
+ justifyContent: "center",
2631
+ background: settingsOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2632
+ border: "none",
2633
+ padding: 0,
2634
+ cursor: "pointer",
2635
+ color: settingsOpen ? "#9db8ff" : "#8b96ab"
2636
+ },
2637
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2638
+ width: "16",
2639
+ height: "16",
2640
+ viewBox: "0 0 24 24",
2641
+ fill: "none",
1269
2642
  stroke: "currentColor",
1270
2643
  strokeWidth: "2",
2644
+ strokeLinecap: "round",
1271
2645
  strokeLinejoin: "round",
1272
2646
  "aria-hidden": "true",
1273
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
2647
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2648
+ cx: "12",
2649
+ cy: "12",
2650
+ r: "3"
2651
+ }), /* @__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" })]
1274
2652
  })
1275
2653
  }),
1276
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2654
+ toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
2655
+ settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2656
+ ref: settingsRef,
2657
+ "data-toolbar-settings-panel": true,
2658
+ style: {
2659
+ position: "fixed",
2660
+ top: railBox.top,
2661
+ right: railBox.right + DOT_HIT + 8,
2662
+ width: "min(240px, calc(100vw - 48px))",
2663
+ padding: "10px 12px",
2664
+ background: "rgba(20, 24, 32, 0.97)",
2665
+ color: "#e6e8ee",
2666
+ borderRadius: 8,
2667
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
2668
+ zIndex: 104
2669
+ },
2670
+ children: [
2671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-toolbar-pin-toggle]:hover { background: rgba(77, 124, 254, 0.18); }` }),
2672
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2673
+ "data-toolbar-settings-title": true,
2674
+ style: {
2675
+ fontSize: 13,
2676
+ fontWeight: 600,
2677
+ color: "#e6e8ee",
2678
+ marginBottom: 2
2679
+ },
2680
+ children: t("settings.title")
2681
+ }),
2682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2683
+ style: {
2684
+ fontSize: 12,
2685
+ color: "#8b96ab",
2686
+ marginBottom: 4,
2687
+ textAlign: "right"
2688
+ },
2689
+ children: t("settings.pin")
2690
+ }),
2691
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2692
+ role: "menu",
2693
+ "aria-label": t("settings.title"),
2694
+ style: {
2695
+ display: "flex",
2696
+ flexDirection: "column",
2697
+ gap: 2
2698
+ },
2699
+ children: toolbarFeatures.map((feature) => {
2700
+ const checked = pinned.includes(feature.id);
2701
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2702
+ type: "button",
2703
+ role: "menuitemcheckbox",
2704
+ "data-toolbar-pin-toggle": true,
2705
+ "data-pin-id": feature.id,
2706
+ "aria-checked": checked,
2707
+ onClick: () => onTogglePin(feature.id),
2708
+ style: {
2709
+ display: "flex",
2710
+ alignItems: "center",
2711
+ justifyContent: "space-between",
2712
+ gap: 8,
2713
+ width: "100%",
2714
+ padding: "6px 8px",
2715
+ background: "transparent",
2716
+ border: "none",
2717
+ borderRadius: 6,
2718
+ cursor: "pointer",
2719
+ color: "#e6e8ee",
2720
+ fontSize: 13,
2721
+ textAlign: "left"
2722
+ },
2723
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2724
+ "aria-hidden": "true",
2725
+ style: {
2726
+ width: 14,
2727
+ height: 14,
2728
+ flexShrink: 0,
2729
+ borderRadius: 3,
2730
+ border: "1px solid rgba(255, 255, 255, 0.35)",
2731
+ background: checked ? "rgba(77, 124, 254, 0.9)" : "transparent",
2732
+ display: "flex",
2733
+ alignItems: "center",
2734
+ justifyContent: "center",
2735
+ color: "#ffffff",
2736
+ fontSize: 10,
2737
+ lineHeight: 1
2738
+ },
2739
+ children: checked ? "✓" : ""
2740
+ })]
2741
+ }, feature.id);
2742
+ })
2743
+ }),
2744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2745
+ type: "button",
2746
+ "data-toolbar-settings-reset": true,
2747
+ onClick: onResetPins,
2748
+ style: {
2749
+ width: "100%",
2750
+ marginTop: 6,
2751
+ padding: "6px 8px",
2752
+ background: "transparent",
2753
+ border: "none",
2754
+ borderRadius: 6,
2755
+ cursor: "pointer",
2756
+ color: "#8b96ab",
2757
+ fontSize: 13,
2758
+ textAlign: "left"
2759
+ },
2760
+ children: t("settings.reset")
2761
+ }),
2762
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2763
+ "data-toolbar-settings-footer": true,
2764
+ style: {
2765
+ marginTop: 8,
2766
+ paddingTop: 8,
2767
+ borderTop: "1px solid rgba(255, 255, 255, 0.12)"
2768
+ },
2769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2770
+ style: {
2771
+ fontSize: 12,
2772
+ color: "#8b96ab",
2773
+ marginBottom: 6
2774
+ },
2775
+ children: t("settings.support")
2776
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2777
+ style: {
2778
+ display: "flex",
2779
+ flexDirection: "column",
2780
+ gap: 4
2781
+ },
2782
+ children: [
2783
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2784
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2785
+ target: "_blank",
2786
+ rel: "noreferrer",
2787
+ style: {
2788
+ fontSize: 12,
2789
+ color: "#9db8ff",
2790
+ textDecoration: "none"
2791
+ },
2792
+ children: t("settings.repo")
2793
+ }),
2794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2795
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2796
+ target: "_blank",
2797
+ rel: "noreferrer",
2798
+ style: {
2799
+ fontSize: 12,
2800
+ color: "#9db8ff",
2801
+ textDecoration: "none"
2802
+ },
2803
+ children: t("settings.star")
2804
+ }),
2805
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2806
+ href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
2807
+ target: "_blank",
2808
+ rel: "noreferrer",
2809
+ style: {
2810
+ fontSize: 12,
2811
+ color: "#9db8ff",
2812
+ textDecoration: "none"
2813
+ },
2814
+ children: t("settings.issues")
2815
+ }),
2816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2817
+ href: "https://www.npmjs.com/package/dsh-milestone",
2818
+ target: "_blank",
2819
+ rel: "noreferrer",
2820
+ style: {
2821
+ fontSize: 12,
2822
+ color: "#9db8ff",
2823
+ textDecoration: "none"
2824
+ },
2825
+ children: t("settings.npm")
2826
+ })
2827
+ ]
2828
+ })]
2829
+ })
2830
+ ]
2831
+ }),
2832
+ updateOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2833
+ ref: updatePanelRef,
2834
+ "data-update-panel": true,
2835
+ style: {
2836
+ position: "fixed",
2837
+ top: railBox.top,
2838
+ right: railBox.right + DOT_HIT + 8,
2839
+ width: "min(280px, calc(100vw - 48px))",
2840
+ padding: "10px 12px",
2841
+ background: "rgba(20, 24, 32, 0.97)",
2842
+ color: "#e6e8ee",
2843
+ borderRadius: 8,
2844
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
2845
+ zIndex: 104
2846
+ },
2847
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2848
+ "data-update-title": true,
2849
+ style: {
2850
+ fontSize: 13,
2851
+ fontWeight: 600,
2852
+ color: "#e6e8ee",
2853
+ marginBottom: 8
2854
+ },
2855
+ children: t("update.title")
2856
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2857
+ style: {
2858
+ display: "flex",
2859
+ flexDirection: "column",
2860
+ gap: 6,
2861
+ fontSize: 12,
2862
+ lineHeight: 1.5
2863
+ },
2864
+ children: [
2865
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2866
+ style: { color: "#8b96ab" },
2867
+ children: [t("update.current"), ": "]
2868
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.1" })] }),
2869
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2870
+ "data-update-latest": true,
2871
+ children: [
2872
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2873
+ style: { color: "#8b96ab" },
2874
+ children: [t("update.latest"), ": "]
2875
+ }),
2876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: updateCheck.latest }),
2877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2878
+ style: { color: "#8b96ab" },
2879
+ children: [
2880
+ " (",
2881
+ updateCheck.source,
2882
+ ")"
2883
+ ]
2884
+ })
2885
+ ]
2886
+ }),
2887
+ updateCheck.phase === "checking" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2888
+ "data-update-status": true,
2889
+ style: { color: "#8b96ab" },
2890
+ children: t("update.checking")
2891
+ }),
2892
+ updateCheck.phase === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2893
+ "data-update-failed": true,
2894
+ children: [
2895
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [t("update.failed"), ":"] }),
2896
+ " ",
2897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2898
+ style: { color: "#8b96ab" },
2899
+ children: updateCheck.error
2900
+ }),
2901
+ " ",
2902
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2903
+ type: "button",
2904
+ "data-update-retry": true,
2905
+ onClick: runUpdateCheck,
2906
+ style: {
2907
+ background: "transparent",
2908
+ border: "none",
2909
+ padding: 0,
2910
+ cursor: "pointer",
2911
+ color: "#9db8ff",
2912
+ fontSize: 12,
2913
+ textDecoration: "underline"
2914
+ },
2915
+ children: t("update.retry")
2916
+ })
2917
+ ]
2918
+ }),
2919
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2920
+ "data-update-conclusion": true,
2921
+ children: updateCheck.available ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2922
+ t("update.available"),
2923
+ " v",
2924
+ updateCheck.latest,
2925
+ " → "
2926
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2927
+ href: "https://www.npmjs.com/package/dsh-milestone",
2928
+ target: "_blank",
2929
+ rel: "noreferrer",
2930
+ style: {
2931
+ color: "#9db8ff",
2932
+ textDecoration: "none"
2933
+ },
2934
+ children: t("update.goNpm")
2935
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2936
+ style: { color: "#7ee2a8" },
2937
+ children: t("update.upToDate")
2938
+ })
2939
+ }),
2940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2941
+ "data-update-host-lines": true,
2942
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2943
+ style: { color: "#8b96ab" },
2944
+ children: [t("update.hostLines"), ": "]
2945
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: SUPPORTED_HOST_LINES.map(hostLineLabel).join("、") })]
2946
+ }),
2947
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2948
+ type: "button",
2949
+ "data-update-manual": true,
2950
+ disabled: updateCheck.phase === "checking",
2951
+ onClick: runUpdateCheck,
2952
+ style: {
2953
+ marginTop: 4,
2954
+ padding: "6px 10px",
2955
+ background: updateCheck.phase === "checking" ? "transparent" : "rgba(77, 124, 254, 0.18)",
2956
+ border: "none",
2957
+ borderRadius: 6,
2958
+ cursor: updateCheck.phase === "checking" ? "default" : "pointer",
2959
+ color: updateCheck.phase === "checking" ? "#5a6375" : "#9db8ff",
2960
+ fontSize: 12,
2961
+ alignSelf: "flex-start"
2962
+ },
2963
+ children: updateCheck.phase === "checking" ? t("update.checking") : t("update.check")
2964
+ })
2965
+ ]
2966
+ })]
2967
+ }),
2968
+ listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
1277
2969
  panelTop: railBox.top,
1278
2970
  panelRight: railBox.right + DOT_HIT + 8,
1279
- query: search.query,
1280
- panelOpen: search.panelOpen,
1281
- matches: matches.length,
1282
- total: displayMarks.length,
1283
- onToggle: () => setSearch((s) => ({
1284
- ...s,
1285
- panelOpen: !s.panelOpen
1286
- })),
1287
- onQueryChange: updateQuery,
1288
- onSearchKeyDown,
1289
- onClear: clearSearch,
2971
+ marks,
2972
+ onJump: jump,
2973
+ t
2974
+ }),
2975
+ crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
2976
+ panelTop: railBox.top,
2977
+ panelRight: railBox.right + DOT_HIT + 8,
2978
+ onClose: () => setCrossOpen(false),
2979
+ searchSessions,
2980
+ openSession,
1290
2981
  t
1291
2982
  }),
1292
2983
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1421,7 +3112,7 @@ window.__ModuleLoader__.load({
1421
3112
  right: "100%",
1422
3113
  marginRight: 8,
1423
3114
  whiteSpace: "nowrap",
1424
- fontSize: 10,
3115
+ fontSize: 12,
1425
3116
  lineHeight: 1,
1426
3117
  color: "rgba(139, 150, 171, 0.9)",
1427
3118
  pointerEvents: "none",
@@ -1465,6 +3156,46 @@ window.__ModuleLoader__.load({
1465
3156
  //#endregion
1466
3157
  //#region src/client/railInject.ts
1467
3158
  /**
3159
+ * Wrap the `session.search` RPC into a safe cross-session search action.
3160
+ *
3161
+ * - `ok: true` unwraps the value and joins each hit's human display title
3162
+ * from the session list snapshot (a session outside the list keeps no
3163
+ * title — the caller falls back to `search.untitled`).
3164
+ * - `ok: false` throws `new Error(error.message)` so the caller can surface
3165
+ * the business/transport error as the `search.error` state.
3166
+ * - A rejected RPC propagates unchanged.
3167
+ *
3168
+ * The list read happens inside the returned closure (never at factory time),
3169
+ * so the title join always reflects the current list snapshot.
3170
+ *
3171
+ * @param sessions - the injected sessions service (`ctx.sessions`).
3172
+ * @returns an action that searches all sessions' message content.
3173
+ */
3174
+ function createSessionSearch(sessions) {
3175
+ return async (query, signal) => {
3176
+ const result = await sessions.search(query, signal);
3177
+ if (!result.ok) throw new Error(result.error.message);
3178
+ const byId = sessions.list.getSnapshot().byId;
3179
+ return {
3180
+ items: result.value.items.map((item) => ({
3181
+ ...item,
3182
+ title: byId[item.sessionId]?.displayTitle
3183
+ })),
3184
+ hasMore: result.value.hasMore
3185
+ };
3186
+ };
3187
+ }
3188
+ /**
3189
+ * Wrap a session `open` call into a safe action that selects a listed session
3190
+ * as current — the exact selection path the sidebar uses on click.
3191
+ *
3192
+ * @param sessions - the injected sessions service (`ctx.sessions`).
3193
+ * @returns an action that opens the given session.
3194
+ */
3195
+ function createOpenSession(sessions) {
3196
+ return (id) => sessions.open(id);
3197
+ }
3198
+ /**
1468
3199
  * Wrap a session-bound `loadOlder` call into a safe action closure.
1469
3200
  *
1470
3201
  * - Missing binding: resolves (never throws on an unlisted/unscoped session).
@@ -1530,6 +3261,10 @@ window.__ModuleLoader__.load({
1530
3261
  "search.label": "搜索消息",
1531
3262
  /** aria-label on the bookmarks-only filter toggle. */
1532
3263
  "bookmark.filter": "只看收藏",
3264
+ /** aria-label + title on the focus-mode toggle when focus is OFF (arm it). */
3265
+ "focus.on": "聚焦模式",
3266
+ /** aria-label + title on the focus-mode toggle when focus is ON (disarm it). */
3267
+ "focus.off": "退出聚焦",
1533
3268
  /** aria-label on the hover tooltip star toggle. */
1534
3269
  "bookmark.star": "收藏此消息",
1535
3270
  /** aria-label on the search clear button. */
@@ -1573,7 +3308,71 @@ window.__ModuleLoader__.load({
1573
3308
  /** Collapse-turn tooltip action. */
1574
3309
  "collapse.turn": "折叠此轮",
1575
3310
  /** Expand-turn tooltip action. */
1576
- "expand.turn": "展开此轮"
3311
+ "expand.turn": "展开此轮",
3312
+ /** aria-label + title on the milestone-list toggle when the panel is CLOSED. */
3313
+ "list.open": "打开列表",
3314
+ /** aria-label + title on the milestone-list toggle when the panel is OPEN. */
3315
+ "list.close": "收起列表",
3316
+ /** Header title of the all-prompts list panel. */
3317
+ "list.label": "全部提问",
3318
+ /** Header title + input placeholder of the cross-session search panel. */
3319
+ "search.cross": "跨会话搜索",
3320
+ /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
3321
+ "search.cross.open": "打开跨会话搜索",
3322
+ /** aria-label + title on the cross-session search toggle when the panel is OPEN. */
3323
+ "search.cross.close": "收起跨会话搜索",
3324
+ /** Cross-session result row title fallback for sessions with no display title. */
3325
+ "search.untitled": "(无标题)",
3326
+ /** Cross-session search failure notice. */
3327
+ "search.error": "搜索失败,请重试",
3328
+ /** Cross-session search footer hint when the harness capped the result list. */
3329
+ "search.more": "结果已截断,请细化关键词",
3330
+ /** aria-label on the toolbar expand arrow while the toolbar is COLLAPSED (expand it). */
3331
+ "toolbar.expand": "展开工具栏",
3332
+ /** aria-label on the toolbar expand arrow while the toolbar is EXPANDED (collapse it). */
3333
+ "toolbar.collapse": "收起工具栏",
3334
+ /** aria-label on the toolbar settings gear while the settings menu is CLOSED. */
3335
+ "toolbar.settings.open": "打开设置",
3336
+ /** aria-label on the toolbar settings gear while the settings menu is OPEN. */
3337
+ "toolbar.settings.close": "关闭设置",
3338
+ /** Header title of the toolbar settings menu. */
3339
+ "settings.title": "设置",
3340
+ /** Per-feature toggle label inside the settings menu: keep visible while collapsed. */
3341
+ "settings.pin": "在折叠外显示",
3342
+ /** Settings action that clears every pinned feature. */
3343
+ "settings.reset": "恢复默认",
3344
+ /** Settings footer heading above the project links. */
3345
+ "settings.support": "支持我们",
3346
+ /** Settings footer link: the GitHub repository. */
3347
+ "settings.repo": "GitHub 仓库",
3348
+ /** Settings footer link: star the repository. */
3349
+ "settings.star": "欢迎 Star ★",
3350
+ /** Settings footer link: file an issue. */
3351
+ "settings.issues": "提交 Issue",
3352
+ /** Settings footer link: the npm install channel. */
3353
+ "settings.npm": "npm 安装渠道",
3354
+ /** B4 update-check: toolbar button label + title/aria-label. */
3355
+ "update.check": "检查更新",
3356
+ /** B4 update-check: popover title. */
3357
+ "update.title": "更新检测",
3358
+ /** B4 update-check: installed-version row label. */
3359
+ "update.current": "当前版本",
3360
+ /** B4 update-check: newest-published-version row label. */
3361
+ "update.latest": "最新版本",
3362
+ /** B4 update-check: conclusion when the installed version is current. */
3363
+ "update.upToDate": "已是最新版本",
3364
+ /** B4 update-check: conclusion when a newer version exists. */
3365
+ "update.available": "发现新版本",
3366
+ /** B4 update-check: link text for the npm upgrade channel. */
3367
+ "update.goNpm": "去 npm 升级",
3368
+ /** B4 update-check: supported-host-lines metadata row label. */
3369
+ "update.hostLines": "已适配官方版本线",
3370
+ /** B4 update-check: in-flight state of the manual check button. */
3371
+ "update.checking": "检查中…",
3372
+ /** B4 update-check: failed state heading. */
3373
+ "update.failed": "检查失败",
3374
+ /** B4 update-check: retry action inside the failed state. */
3375
+ "update.retry": "重试"
1577
3376
  };
1578
3377
  const en = {
1579
3378
  "jump.to": "Jump to message {n}",
@@ -1583,6 +3382,8 @@ window.__ModuleLoader__.load({
1583
3382
  "search.placeholder": "Search message content",
1584
3383
  "search.label": "Search messages",
1585
3384
  "bookmark.filter": "Bookmarks only",
3385
+ "focus.on": "Focus mode",
3386
+ "focus.off": "Exit focus",
1586
3387
  "bookmark.star": "Bookmark this message",
1587
3388
  "search.clear": "Clear search",
1588
3389
  "load.older": "Load older messages",
@@ -1604,7 +3405,39 @@ window.__ModuleLoader__.load({
1604
3405
  "copy.message": "Copy message",
1605
3406
  "fork.here": "Fork from here",
1606
3407
  "collapse.turn": "Collapse turn",
1607
- "expand.turn": "Expand turn"
3408
+ "expand.turn": "Expand turn",
3409
+ "list.open": "Open list",
3410
+ "list.close": "Close list",
3411
+ "list.label": "All prompts",
3412
+ "search.cross": "Cross-session search",
3413
+ "search.cross.open": "Open cross-session search",
3414
+ "search.cross.close": "Close cross-session search",
3415
+ "search.untitled": "(untitled)",
3416
+ "search.error": "Search failed, retry",
3417
+ "search.more": "Results truncated — refine your query",
3418
+ "toolbar.expand": "Expand toolbar",
3419
+ "toolbar.collapse": "Collapse toolbar",
3420
+ "toolbar.settings.open": "Open settings",
3421
+ "toolbar.settings.close": "Close settings",
3422
+ "settings.title": "Settings",
3423
+ "settings.pin": "Show outside collapse",
3424
+ "settings.reset": "Restore defaults",
3425
+ "settings.support": "Support us",
3426
+ "settings.repo": "GitHub repo",
3427
+ "settings.star": "Give us a Star ★",
3428
+ "settings.issues": "Report an Issue",
3429
+ "settings.npm": "Install via npm",
3430
+ "update.check": "Check updates",
3431
+ "update.title": "Update check",
3432
+ "update.current": "Current version",
3433
+ "update.latest": "Latest version",
3434
+ "update.upToDate": "You are up to date",
3435
+ "update.available": "Update available",
3436
+ "update.goNpm": "Upgrade on npm",
3437
+ "update.hostLines": "Supported official version lines",
3438
+ "update.checking": "Checking…",
3439
+ "update.failed": "Check failed",
3440
+ "update.retry": "Retry"
1608
3441
  };
1609
3442
  //#endregion
1610
3443
  //#region src/client/index.ts
@@ -1641,7 +3474,9 @@ window.__ModuleLoader__.load({
1641
3474
  locale: "dsh-milestone",
1642
3475
  inject: (sessionId) => ({
1643
3476
  loadOlder: createLoadOlder(ctx.sessions, sessionId),
1644
- forkAt: createForkAt(ctx.sessions, sessionId)
3477
+ forkAt: createForkAt(ctx.sessions, sessionId),
3478
+ searchSessions: createSessionSearch(ctx.sessions),
3479
+ openSession: createOpenSession(ctx.sessions)
1645
3480
  })
1646
3481
  }, MilestoneRail));
1647
3482
  }