dsh-milestone 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/lib/client.js +672 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,6 +44,10 @@
44
44
  - **turn 分组折叠** —— 圆点按轮次分组,一眼看清对话章节;长轮次可折叠成一条,减少干扰。
45
45
  - **复制与 fork** —— 悬停一键复制该条提问全文,或「从此处 fork」开一个分支会话。
46
46
  - **中英双语** —— 跟随 harness 界面语言自动切换中英文。
47
+ - **聚焦模式** —— 一键淡化 AI 的思考(thinking)区块,对话正文更清爽;悬停或展开时自动恢复。
48
+ - **全部提问列表** —— 顶部展开面板,一次看全所有提问(序号 + 轮次 + 预览),点击即跳,长对话不再靠圆点一个个数。
49
+ - **深链接** —— 跳转时 URL 带上 `#msg=` 锚点,刷新或分享链接后直接回到同一条消息。
50
+ - **跨会话搜索** —— 一键搜索**所有会话**的消息内容(harness 原生索引),点击结果直接打开对应会话。
47
51
  - **零侵入** —— 官方 slot 机制挂载,不修改 harness 源码,装完即用。
48
52
 
49
53
  ## 悬停能看到什么
@@ -100,6 +104,9 @@ shell.overlay (root scope)
100
104
  - 书签按**会话**隔离(不跨会话共享)。
101
105
  - 模型 / token 用量依赖该轮 assistant 节点的元数据,部分场景下缺失则自动隐藏该行。
102
106
  - fork 从选中消息所在轮次开始分支,不会自动打开子会话(需在会话列表手动打开)。
107
+ - 深链接的目标消息若早于已加载窗口,会先自动加载更早历史再定位(受加载上限约束,极端深的历史可能定位失败)。
108
+ - 跨会话搜索依赖 harness 的消息内容索引(`session.search`),仅返回片段(≤240 字符)、最多 20 条结果;命中过多时请细化关键词。
109
+ - 聚焦模式作用于当前会话视图的思考区块,不会影响其他插件或工具的展示。
103
110
  - 尚无全局快捷键聚焦里程碑条(需 Tab 键切换到)。
104
111
 
105
112
  ## License
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;
@@ -452,7 +505,7 @@ window.__ModuleLoader__.load({
452
505
  //#endregion
453
506
  //#region src/client/MilestoneRailSearch.tsx
454
507
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
455
- const DOT_HIT$1 = 22;
508
+ const DOT_HIT$1 = 28;
456
509
  /**
457
510
  * @param props - the search state slice plus the rail's event handlers.
458
511
  */
@@ -477,8 +530,8 @@ window.__ModuleLoader__.load({
477
530
  color: panelOpen ? "#9db8ff" : "#8b96ab"
478
531
  },
479
532
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
480
- width: "13",
481
- height: "13",
533
+ width: "16",
534
+ height: "16",
482
535
  viewBox: "0 0 24 24",
483
536
  fill: "none",
484
537
  stroke: "currentColor",
@@ -496,7 +549,7 @@ window.__ModuleLoader__.load({
496
549
  position: "fixed",
497
550
  top: panelTop,
498
551
  right: panelRight,
499
- width: 220,
552
+ width: "min(220px, calc(100vw - 48px))",
500
553
  padding: "10px 12px",
501
554
  background: "rgba(20, 24, 32, 0.97)",
502
555
  color: "#e6e8ee",
@@ -521,8 +574,9 @@ window.__ModuleLoader__.load({
521
574
  style: {
522
575
  flex: 1,
523
576
  minWidth: 0,
524
- padding: "5px 8px",
525
- fontSize: 12,
577
+ padding: "6px 10px",
578
+ fontSize: 14,
579
+ lineHeight: 1.4,
526
580
  color: "#e6e8ee",
527
581
  background: "rgba(255, 255, 255, 0.08)",
528
582
  border: "1px solid rgba(255, 255, 255, 0.16)",
@@ -548,8 +602,8 @@ window.__ModuleLoader__.load({
548
602
  color: "#8b96ab"
549
603
  },
550
604
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
551
- width: "10",
552
- height: "10",
605
+ width: "12",
606
+ height: "12",
553
607
  viewBox: "0 0 24 24",
554
608
  fill: "none",
555
609
  stroke: "currentColor",
@@ -563,7 +617,7 @@ window.__ModuleLoader__.load({
563
617
  "data-match-count": true,
564
618
  style: {
565
619
  marginTop: 6,
566
- fontSize: 11,
620
+ fontSize: 13,
567
621
  color: "#8b96ab"
568
622
  },
569
623
  children: [
@@ -575,6 +629,101 @@ window.__ModuleLoader__.load({
575
629
  })] });
576
630
  }
577
631
  //#endregion
632
+ //#region src/client/MilestoneListPanel.tsx
633
+ /**
634
+ * @param props - the panel anchor, the full marks array, and the rail's jump handler.
635
+ */
636
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, t }) {
637
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
638
+ "data-milestone-list": true,
639
+ style: {
640
+ position: "fixed",
641
+ top: panelTop,
642
+ right: panelRight,
643
+ width: "min(280px, calc(100vw - 48px))",
644
+ padding: "10px 12px",
645
+ background: "rgba(20, 24, 32, 0.97)",
646
+ color: "#e6e8ee",
647
+ borderRadius: 8,
648
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
649
+ zIndex: 103
650
+ },
651
+ children: [
652
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-list-item]:hover { background: rgba(77, 124, 254, 0.18); }` }),
653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
654
+ style: {
655
+ display: "flex",
656
+ alignItems: "center",
657
+ gap: 6,
658
+ marginBottom: 6
659
+ },
660
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
661
+ style: {
662
+ fontSize: 13,
663
+ fontWeight: 600,
664
+ color: "#e6e8ee"
665
+ },
666
+ children: t("list.label")
667
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
668
+ style: {
669
+ fontSize: 12,
670
+ color: "#8b96ab"
671
+ },
672
+ children: marks.length
673
+ })]
674
+ }),
675
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
676
+ style: {
677
+ maxHeight: 300,
678
+ overflowY: "auto",
679
+ display: "flex",
680
+ flexDirection: "column",
681
+ gap: 2
682
+ },
683
+ children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
684
+ type: "button",
685
+ "data-list-item": true,
686
+ "data-jump-key": mark.key,
687
+ onClick: () => onJump(mark.key),
688
+ title: mark.preview,
689
+ style: {
690
+ display: "block",
691
+ width: "100%",
692
+ minWidth: 0,
693
+ padding: "6px 8px",
694
+ background: "transparent",
695
+ border: "none",
696
+ borderRadius: 6,
697
+ cursor: "pointer",
698
+ color: "#e6e8ee",
699
+ textAlign: "left"
700
+ },
701
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
702
+ style: {
703
+ fontSize: 12,
704
+ color: "#8b96ab",
705
+ whiteSpace: "nowrap"
706
+ },
707
+ children: [t("pos.of", {
708
+ n: i + 1,
709
+ m: marks.length
710
+ }), mark.turn !== void 0 ? ` · ${t("turn.label", { n: mark.turn })}` : null]
711
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
712
+ style: {
713
+ fontSize: 13,
714
+ lineHeight: 1.4,
715
+ overflow: "hidden",
716
+ textOverflow: "ellipsis",
717
+ whiteSpace: "nowrap"
718
+ },
719
+ children: mark.preview || t("no.text")
720
+ })]
721
+ }, mark.key))
722
+ })
723
+ ]
724
+ });
725
+ }
726
+ //#endregion
578
727
  //#region src/client/MilestoneRailTooltip.tsx
579
728
  /**
580
729
  * @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
@@ -591,14 +740,14 @@ window.__ModuleLoader__.load({
591
740
  right: panelRight,
592
741
  top: hover.top,
593
742
  transform: "translateY(-50%)",
594
- maxWidth: 300,
743
+ maxWidth: "min(300px, calc(100vw - 120px))",
595
744
  minWidth: 180,
596
745
  padding: "8px 12px",
597
746
  background: "rgba(20, 24, 32, 0.96)",
598
747
  color: "#e6e8ee",
599
748
  borderRadius: 8,
600
- fontSize: 12,
601
- lineHeight: 1.6,
749
+ fontSize: "var(--dsw-font-s-14, 14px)",
750
+ lineHeight: 1.5,
602
751
  whiteSpace: "pre-wrap",
603
752
  wordBreak: "break-word",
604
753
  boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
@@ -613,7 +762,8 @@ window.__ModuleLoader__.load({
613
762
  flexWrap: "wrap",
614
763
  gap: 8,
615
764
  color: "#9aa4b8",
616
- fontSize: 11,
765
+ fontSize: 13,
766
+ lineHeight: 1.4,
617
767
  marginBottom: 4
618
768
  },
619
769
  children: [
@@ -647,8 +797,8 @@ window.__ModuleLoader__.load({
647
797
  color: bookmarked ? "#ffd166" : "#8b96ab"
648
798
  },
649
799
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
650
- width: "13",
651
- height: "13",
800
+ width: "14",
801
+ height: "14",
652
802
  viewBox: "0 0 24 24",
653
803
  fill: bookmarked ? "currentColor" : "none",
654
804
  stroke: "currentColor",
@@ -673,7 +823,7 @@ window.__ModuleLoader__.load({
673
823
  justifyContent: "center",
674
824
  background: "transparent",
675
825
  border: "none",
676
- padding: "2px 6px",
826
+ padding: "3px 8px",
677
827
  cursor: "pointer",
678
828
  whiteSpace: "nowrap",
679
829
  color: copied ? "#7ee2a8" : "#8b96ab"
@@ -695,7 +845,7 @@ window.__ModuleLoader__.load({
695
845
  justifyContent: "center",
696
846
  background: "transparent",
697
847
  border: "none",
698
- padding: "2px 6px",
848
+ padding: "3px 8px",
699
849
  cursor: "pointer",
700
850
  whiteSpace: "nowrap",
701
851
  color: forked ? "#7ee2a8" : "#8b96ab"
@@ -718,7 +868,7 @@ window.__ModuleLoader__.load({
718
868
  justifyContent: "center",
719
869
  background: "transparent",
720
870
  border: "none",
721
- padding: "2px 6px",
871
+ padding: "3px 8px",
722
872
  cursor: "pointer",
723
873
  whiteSpace: "nowrap",
724
874
  color: turnCollapsed ? "#7ee2a8" : "#8b96ab"
@@ -737,7 +887,8 @@ window.__ModuleLoader__.load({
737
887
  flexWrap: "wrap",
738
888
  gap: 8,
739
889
  color: "#8b96ab",
740
- fontSize: 11,
890
+ fontSize: 13,
891
+ lineHeight: 1.4,
741
892
  marginTop: 4
742
893
  },
743
894
  children: [
@@ -754,7 +905,8 @@ window.__ModuleLoader__.load({
754
905
  flexWrap: "wrap",
755
906
  gap: 8,
756
907
  color: "#8b96ab",
757
- fontSize: 11,
908
+ fontSize: 13,
909
+ lineHeight: 1.4,
758
910
  marginTop: 4
759
911
  },
760
912
  children: [
@@ -776,6 +928,185 @@ window.__ModuleLoader__.load({
776
928
  });
777
929
  }
778
930
  //#endregion
931
+ //#region src/client/MilestoneSessionSearch.tsx
932
+ /**
933
+ * MilestoneSessionSearch: the cross-session search panel (P3) — the fixed
934
+ * chrome pinned to the rail's top that searches EVERY session's message
935
+ * content through the injected `searchSessions` action (the harness
936
+ * `session.search` RPC), lists ranked hits (display title + snippet), and
937
+ * opens the clicked session via `openSession` (the same selection path the
938
+ * sidebar uses).
939
+ *
940
+ * Owns its search lifecycle — debounce + AbortController + status — unlike
941
+ * the rail's in-session search (RailSearchUi), which is a pure presentation
942
+ * slice of MilestoneRail's own state. Mirrors MilestoneListPanel's
943
+ * fixed-panel styling. Renders one DOM contract
944
+ * (`data-session-search` root / `data-session-search-input` /
945
+ * `data-session-search-result` rows / `data-session-search-error` /
946
+ * `data-session-search-more`).
947
+ */
948
+ /** Debounce window for the cross-session query (ms). */
949
+ const SEARCH_DEBOUNCE_MS = 250;
950
+ /**
951
+ * @param props - the panel anchor, the close/open/search actions, and the locale interpreter.
952
+ */
953
+ function MilestoneSessionSearch({ panelTop, panelRight, onClose, searchSessions, openSession, t }) {
954
+ const [query, setQuery] = (0, react.useState)("");
955
+ const [status, setStatus] = (0, react.useState)("idle");
956
+ const [hits, setHits] = (0, react.useState)([]);
957
+ const [hasMore, setHasMore] = (0, react.useState)(false);
958
+ (0, react.useEffect)(() => {
959
+ const trimmed = query.trim();
960
+ if (trimmed === "") {
961
+ setStatus("idle");
962
+ return;
963
+ }
964
+ const controller = new AbortController();
965
+ setStatus("loading");
966
+ const timer = window.setTimeout(() => {
967
+ searchSessions(trimmed, controller.signal).then((result) => {
968
+ if (controller.signal.aborted) return;
969
+ setHits(result.items);
970
+ setHasMore(result.hasMore);
971
+ setStatus(result.items.length > 0 ? "results" : "empty");
972
+ }, () => {
973
+ if (controller.signal.aborted) return;
974
+ setStatus("error");
975
+ });
976
+ }, SEARCH_DEBOUNCE_MS);
977
+ return () => {
978
+ window.clearTimeout(timer);
979
+ controller.abort();
980
+ };
981
+ }, [query, searchSessions]);
982
+ const onInputKeyDown = (e) => {
983
+ if (e.key === "Escape") onClose();
984
+ };
985
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
986
+ "data-session-search": true,
987
+ style: {
988
+ position: "fixed",
989
+ top: panelTop,
990
+ right: panelRight,
991
+ width: "min(280px, calc(100vw - 48px))",
992
+ padding: "10px 12px",
993
+ background: "rgba(20, 24, 32, 0.97)",
994
+ color: "#e6e8ee",
995
+ borderRadius: 8,
996
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
997
+ zIndex: 103
998
+ },
999
+ children: [
1000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-session-search-result]:hover { background: rgba(77, 124, 254, 0.18); }` }),
1001
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1002
+ style: {
1003
+ display: "flex",
1004
+ alignItems: "center",
1005
+ gap: 6,
1006
+ marginBottom: 6
1007
+ },
1008
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1009
+ style: {
1010
+ fontSize: 13,
1011
+ fontWeight: 600,
1012
+ color: "#e6e8ee"
1013
+ },
1014
+ children: t("search.cross")
1015
+ })
1016
+ }),
1017
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1018
+ "data-session-search-input": true,
1019
+ "aria-label": t("search.cross"),
1020
+ placeholder: t("search.cross"),
1021
+ value: query,
1022
+ onChange: (e) => setQuery(e.target.value),
1023
+ onKeyDown: onInputKeyDown,
1024
+ autoFocus: true,
1025
+ style: {
1026
+ boxSizing: "border-box",
1027
+ width: "100%",
1028
+ padding: "6px 10px",
1029
+ fontSize: 14,
1030
+ lineHeight: 1.4,
1031
+ color: "#e6e8ee",
1032
+ background: "rgba(255, 255, 255, 0.08)",
1033
+ border: "1px solid rgba(255, 255, 255, 0.16)",
1034
+ borderRadius: 6,
1035
+ outline: "none"
1036
+ }
1037
+ }),
1038
+ status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1039
+ "data-session-search-error": true,
1040
+ style: {
1041
+ marginTop: 8,
1042
+ fontSize: 13,
1043
+ color: "#f07c7c"
1044
+ },
1045
+ children: t("search.error")
1046
+ }),
1047
+ status === "results" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1048
+ style: {
1049
+ maxHeight: 300,
1050
+ overflowY: "auto",
1051
+ display: "flex",
1052
+ flexDirection: "column",
1053
+ gap: 2,
1054
+ marginTop: 8
1055
+ },
1056
+ children: hits.map((hit) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1057
+ type: "button",
1058
+ "data-session-search-result": true,
1059
+ onClick: () => {
1060
+ openSession(hit.sessionId);
1061
+ onClose();
1062
+ },
1063
+ title: hit.snippet,
1064
+ style: {
1065
+ display: "block",
1066
+ width: "100%",
1067
+ minWidth: 0,
1068
+ padding: "6px 8px",
1069
+ background: "transparent",
1070
+ border: "none",
1071
+ borderRadius: 6,
1072
+ cursor: "pointer",
1073
+ color: "#e6e8ee",
1074
+ textAlign: "left"
1075
+ },
1076
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1077
+ style: {
1078
+ fontSize: 12,
1079
+ color: "#9db8ff",
1080
+ whiteSpace: "nowrap",
1081
+ overflow: "hidden",
1082
+ textOverflow: "ellipsis"
1083
+ },
1084
+ children: hit.title ?? t("search.untitled")
1085
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1086
+ style: {
1087
+ fontSize: 13,
1088
+ lineHeight: 1.4,
1089
+ color: "#c6ccd8",
1090
+ overflow: "hidden",
1091
+ textOverflow: "ellipsis",
1092
+ whiteSpace: "nowrap"
1093
+ },
1094
+ children: hit.snippet
1095
+ })]
1096
+ }, hit.sessionId))
1097
+ }), hasMore && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1098
+ "data-session-search-more": true,
1099
+ style: {
1100
+ marginTop: 6,
1101
+ fontSize: 12,
1102
+ color: "#8b96ab"
1103
+ },
1104
+ children: t("search.more")
1105
+ })] })
1106
+ ]
1107
+ });
1108
+ }
1109
+ //#endregion
779
1110
  //#region src/client/useCurrentAnchor.ts
780
1111
  /**
781
1112
  * useCurrentAnchor: tracks which user-message row sits at/just above the
@@ -890,15 +1221,38 @@ window.__ModuleLoader__.load({
890
1221
  70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
891
1222
  100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
892
1223
  }`;
1224
+ /**
1225
+ * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
1226
+ * conversation reads cleaner. The rule targets the stable, un-hashed
1227
+ * `data-variant="think"` attribute on the thinking-block ROOT (the harness
1228
+ * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
1229
+ * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
1230
+ * opening it, `[data-open]`) restores full opacity. Kept in an inline
1231
+ * <style> so the plugin stays zero-asset — same pattern as BADGE_PULSE_CSS.
1232
+ */
1233
+ const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
1234
+ [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
893
1235
  /** Visual dot diameter (px). */
894
- const DOT_SIZE = 12;
1236
+ const DOT_SIZE = 14;
895
1237
  /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
896
- const DOT_HIT = 22;
1238
+ const DOT_HIT = 28;
897
1239
  /** Vertical gap between dot hit areas (px) — fixed pitch, never scaled. */
898
- const DOT_GAP = 12;
1240
+ const DOT_GAP = 14;
899
1241
  /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
900
1242
  const RAIL_INSET = 14;
901
1243
  /**
1244
+ * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
1245
+ * deep-link attempt — the harness scrolls the conversation to the bottom on
1246
+ * load, so the deep link must land AFTER the view mounts.
1247
+ */
1248
+ const DEEP_LINK_INITIAL_DELAY = 100;
1249
+ /** P3: interval between DOM-row polls while waiting for the target to render. */
1250
+ const DEEP_LINK_POLL_DELAY = 150;
1251
+ /** P3: polls before falling back to a single `loadOlder` fetch. */
1252
+ const DEEP_LINK_MAX_POLLS = 5;
1253
+ /** P3: bounded polls after `loadOlder`, then the deep link gives up silently. */
1254
+ const DEEP_LINK_MAX_RETRY_POLLS = 5;
1255
+ /**
902
1256
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
903
1257
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
904
1258
  */
@@ -930,7 +1284,10 @@ window.__ModuleLoader__.load({
930
1284
  * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
931
1285
  * outside the slot machinery).
932
1286
  */
933
- function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, t = (key) => key }) {
1287
+ function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1288
+ items: [],
1289
+ hasMore: false
1290
+ }), openSession = () => {}, t = (key) => key }) {
934
1291
  const order = useSession((s) => s.chat.order);
935
1292
  const nodes = useSession((s) => s.chat.nodes);
936
1293
  const locations = useSession((s) => s.chat.locations);
@@ -981,12 +1338,33 @@ window.__ModuleLoader__.load({
981
1338
  panelOpen: false
982
1339
  });
983
1340
  const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
1341
+ const [focusActive, setFocusActive] = (0, react.useState)(false);
1342
+ const [listOpen, setListOpen] = (0, react.useState)(false);
1343
+ const [crossOpen, setCrossOpen] = (0, react.useState)(false);
984
1344
  const [copiedKey, setCopiedKey] = (0, react.useState)(null);
985
1345
  const [forkedKey, setForkedKey] = (0, react.useState)(null);
986
1346
  const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
987
1347
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
988
1348
  const listRef = (0, react.useRef)(null);
989
1349
  const currentKey = useCurrentAnchor(order);
1350
+ /**
1351
+ * P3: jump to the chat row with the given node key — smooth-scroll it into
1352
+ * view and write the position back into the URL hash (`#msg=<key>`) so
1353
+ * refresh and share preserve it. `history.replaceState` (not a
1354
+ * `location.hash` assignment) keeps the history stack clean, and it never
1355
+ * fires `hashchange`, so the deep-link listeners below never echo the
1356
+ * rail's own updates. No-op when the row is not (yet) rendered — the
1357
+ * deep-link mount retry and the load-older flow cover that case.
1358
+ */
1359
+ const jump = (key) => {
1360
+ const row = findRow(key);
1361
+ if (row === null) return;
1362
+ row.scrollIntoView({
1363
+ behavior: "smooth",
1364
+ block: "start"
1365
+ });
1366
+ history.replaceState(null, "", buildMessageHash(key));
1367
+ };
990
1368
  const displayMarks = (0, react.useMemo)(() => {
991
1369
  if (!bookmarksOnly) return marks;
992
1370
  return filterByBookmarks(marks, bookmarkedKeys).visible.map((i) => marks[i]);
@@ -1041,13 +1419,59 @@ window.__ModuleLoader__.load({
1041
1419
  (0, react.useLayoutEffect)(() => {
1042
1420
  setFocusIndex((f) => clampIndex(f, render.items.length));
1043
1421
  }, [render.items.length]);
1422
+ (0, react.useEffect)(() => {
1423
+ if (!listOpen && !crossOpen) return;
1424
+ const onKey = (e) => {
1425
+ if (e.key !== "Escape") return;
1426
+ setListOpen(false);
1427
+ setCrossOpen(false);
1428
+ };
1429
+ window.addEventListener("keydown", onKey);
1430
+ return () => window.removeEventListener("keydown", onKey);
1431
+ }, [listOpen, crossOpen]);
1432
+ const marksRef = (0, react.useRef)(marks);
1433
+ (0, react.useEffect)(() => {
1434
+ marksRef.current = marks;
1435
+ });
1436
+ (0, react.useEffect)(() => {
1437
+ const key = parseDeepLinkHash(window.location.hash);
1438
+ if (key === null) return;
1439
+ let cancelled = false;
1440
+ let timer;
1441
+ const attempt = (pollsLeft, canLoadOlder) => {
1442
+ if (cancelled) return;
1443
+ if (findRow(key) !== null) {
1444
+ jump(key);
1445
+ return;
1446
+ }
1447
+ if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
1448
+ if (pollsLeft > 0) {
1449
+ timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
1450
+ return;
1451
+ }
1452
+ if (canLoadOlder) {
1453
+ loadOlder().then(() => {
1454
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_RETRY_POLLS, false), DEEP_LINK_POLL_DELAY);
1455
+ }, () => {});
1456
+ return;
1457
+ }
1458
+ };
1459
+ timer = window.setTimeout(() => attempt(DEEP_LINK_MAX_POLLS, true), DEEP_LINK_INITIAL_DELAY);
1460
+ return () => {
1461
+ cancelled = true;
1462
+ if (timer !== void 0) window.clearTimeout(timer);
1463
+ };
1464
+ }, []);
1465
+ (0, react.useEffect)(() => {
1466
+ const onHashChange = () => {
1467
+ const key = parseDeepLinkHash(window.location.hash);
1468
+ if (key === null) return;
1469
+ if (marksRef.current.some((m) => m.key === key)) jump(key);
1470
+ };
1471
+ window.addEventListener("hashchange", onHashChange);
1472
+ return () => window.removeEventListener("hashchange", onHashChange);
1473
+ }, []);
1044
1474
  if (railBox === null || marks.length < MIN_MARKS) return null;
1045
- const jump = (key) => {
1046
- findRow(key)?.scrollIntoView({
1047
- behavior: "smooth",
1048
- block: "start"
1049
- });
1050
- };
1051
1475
  const updateQuery = (query) => {
1052
1476
  setSearch({
1053
1477
  query,
@@ -1208,11 +1632,15 @@ window.__ModuleLoader__.load({
1208
1632
  pointerEvents: "auto",
1209
1633
  zIndex: 100,
1210
1634
  display: "flex",
1211
- flexDirection: "column"
1635
+ flexDirection: "column",
1636
+ gap: 6,
1637
+ paddingTop: 6
1212
1638
  },
1213
1639
  "aria-label": t("rail.label"),
1640
+ "data-focus-active": focusActive ? "true" : void 0,
1214
1641
  children: [
1215
1642
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
1643
+ focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
1216
1644
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1217
1645
  type: "button",
1218
1646
  "data-load-older": true,
@@ -1235,7 +1663,7 @@ window.__ModuleLoader__.load({
1235
1663
  padding: 0,
1236
1664
  cursor: loadingOlder ? "default" : "pointer",
1237
1665
  color: loadingOlder ? "#5a6375" : "#8b96ab",
1238
- fontSize: 11,
1666
+ fontSize: 13,
1239
1667
  lineHeight: 1,
1240
1668
  letterSpacing: 1
1241
1669
  },
@@ -1262,8 +1690,8 @@ window.__ModuleLoader__.load({
1262
1690
  color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
1263
1691
  },
1264
1692
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1265
- width: "13",
1266
- height: "13",
1693
+ width: "16",
1694
+ height: "16",
1267
1695
  viewBox: "0 0 24 24",
1268
1696
  fill: bookmarksOnly ? "currentColor" : "none",
1269
1697
  stroke: "currentColor",
@@ -1273,6 +1701,122 @@ window.__ModuleLoader__.load({
1273
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" })
1274
1702
  })
1275
1703
  }),
1704
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1705
+ type: "button",
1706
+ "data-focus-toggle": true,
1707
+ "aria-label": focusActive ? t("focus.off") : t("focus.on"),
1708
+ title: focusActive ? t("focus.off") : t("focus.on"),
1709
+ "aria-pressed": focusActive,
1710
+ onClick: () => setFocusActive((v) => !v),
1711
+ style: {
1712
+ width: DOT_HIT,
1713
+ height: DOT_HIT,
1714
+ flexShrink: 0,
1715
+ display: "flex",
1716
+ alignItems: "center",
1717
+ justifyContent: "center",
1718
+ background: focusActive ? "rgba(126, 226, 168, 0.14)" : "transparent",
1719
+ border: "none",
1720
+ padding: 0,
1721
+ cursor: "pointer",
1722
+ color: focusActive ? "#7ee2a8" : "#8b96ab"
1723
+ },
1724
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1725
+ width: "16",
1726
+ height: "16",
1727
+ viewBox: "0 0 24 24",
1728
+ fill: "none",
1729
+ stroke: "currentColor",
1730
+ strokeWidth: "2",
1731
+ strokeLinecap: "round",
1732
+ strokeLinejoin: "round",
1733
+ "aria-hidden": "true",
1734
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1735
+ cx: "12",
1736
+ cy: "12",
1737
+ r: "3"
1738
+ })]
1739
+ })
1740
+ }),
1741
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1742
+ type: "button",
1743
+ "data-list-toggle": true,
1744
+ "aria-label": listOpen ? t("list.close") : t("list.open"),
1745
+ title: listOpen ? t("list.close") : t("list.open"),
1746
+ "aria-pressed": listOpen,
1747
+ onClick: () => setListOpen((v) => !v),
1748
+ style: {
1749
+ width: DOT_HIT,
1750
+ height: DOT_HIT,
1751
+ flexShrink: 0,
1752
+ display: "flex",
1753
+ alignItems: "center",
1754
+ justifyContent: "center",
1755
+ background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1756
+ border: "none",
1757
+ padding: 0,
1758
+ cursor: "pointer",
1759
+ color: listOpen ? "#9db8ff" : "#8b96ab"
1760
+ },
1761
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1762
+ width: "16",
1763
+ height: "16",
1764
+ viewBox: "0 0 24 24",
1765
+ fill: "none",
1766
+ stroke: "currentColor",
1767
+ strokeWidth: "2.5",
1768
+ strokeLinecap: "round",
1769
+ "aria-hidden": "true",
1770
+ children: [
1771
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
1772
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
1773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
1774
+ ]
1775
+ })
1776
+ }),
1777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1778
+ type: "button",
1779
+ "data-session-search-toggle": true,
1780
+ "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
1781
+ title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
1782
+ "aria-pressed": crossOpen,
1783
+ onClick: () => setCrossOpen((v) => !v),
1784
+ style: {
1785
+ width: DOT_HIT,
1786
+ height: DOT_HIT,
1787
+ flexShrink: 0,
1788
+ display: "flex",
1789
+ alignItems: "center",
1790
+ justifyContent: "center",
1791
+ background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1792
+ border: "none",
1793
+ padding: 0,
1794
+ cursor: "pointer",
1795
+ color: crossOpen ? "#9db8ff" : "#8b96ab"
1796
+ },
1797
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1798
+ width: "16",
1799
+ height: "16",
1800
+ viewBox: "0 0 24 24",
1801
+ fill: "none",
1802
+ stroke: "currentColor",
1803
+ strokeWidth: "2",
1804
+ strokeLinecap: "round",
1805
+ strokeLinejoin: "round",
1806
+ "aria-hidden": "true",
1807
+ children: [
1808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
1809
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
1810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
1811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1812
+ cx: "17",
1813
+ cy: "7",
1814
+ r: "3.5"
1815
+ }),
1816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
1817
+ ]
1818
+ })
1819
+ }),
1276
1820
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
1277
1821
  panelTop: railBox.top,
1278
1822
  panelRight: railBox.right + DOT_HIT + 8,
@@ -1289,6 +1833,21 @@ window.__ModuleLoader__.load({
1289
1833
  onClear: clearSearch,
1290
1834
  t
1291
1835
  }),
1836
+ listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
1837
+ panelTop: railBox.top,
1838
+ panelRight: railBox.right + DOT_HIT + 8,
1839
+ marks,
1840
+ onJump: jump,
1841
+ t
1842
+ }),
1843
+ crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
1844
+ panelTop: railBox.top,
1845
+ panelRight: railBox.right + DOT_HIT + 8,
1846
+ onClose: () => setCrossOpen(false),
1847
+ searchSessions,
1848
+ openSession,
1849
+ t
1850
+ }),
1292
1851
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1293
1852
  ref: listRef,
1294
1853
  "data-rail-list": true,
@@ -1421,7 +1980,7 @@ window.__ModuleLoader__.load({
1421
1980
  right: "100%",
1422
1981
  marginRight: 8,
1423
1982
  whiteSpace: "nowrap",
1424
- fontSize: 10,
1983
+ fontSize: 12,
1425
1984
  lineHeight: 1,
1426
1985
  color: "rgba(139, 150, 171, 0.9)",
1427
1986
  pointerEvents: "none",
@@ -1465,6 +2024,46 @@ window.__ModuleLoader__.load({
1465
2024
  //#endregion
1466
2025
  //#region src/client/railInject.ts
1467
2026
  /**
2027
+ * Wrap the `session.search` RPC into a safe cross-session search action.
2028
+ *
2029
+ * - `ok: true` unwraps the value and joins each hit's human display title
2030
+ * from the session list snapshot (a session outside the list keeps no
2031
+ * title — the caller falls back to `search.untitled`).
2032
+ * - `ok: false` throws `new Error(error.message)` so the caller can surface
2033
+ * the business/transport error as the `search.error` state.
2034
+ * - A rejected RPC propagates unchanged.
2035
+ *
2036
+ * The list read happens inside the returned closure (never at factory time),
2037
+ * so the title join always reflects the current list snapshot.
2038
+ *
2039
+ * @param sessions - the injected sessions service (`ctx.sessions`).
2040
+ * @returns an action that searches all sessions' message content.
2041
+ */
2042
+ function createSessionSearch(sessions) {
2043
+ return async (query, signal) => {
2044
+ const result = await sessions.search(query, signal);
2045
+ if (!result.ok) throw new Error(result.error.message);
2046
+ const byId = sessions.list.getSnapshot().byId;
2047
+ return {
2048
+ items: result.value.items.map((item) => ({
2049
+ ...item,
2050
+ title: byId[item.sessionId]?.displayTitle
2051
+ })),
2052
+ hasMore: result.value.hasMore
2053
+ };
2054
+ };
2055
+ }
2056
+ /**
2057
+ * Wrap a session `open` call into a safe action that selects a listed session
2058
+ * as current — the exact selection path the sidebar uses on click.
2059
+ *
2060
+ * @param sessions - the injected sessions service (`ctx.sessions`).
2061
+ * @returns an action that opens the given session.
2062
+ */
2063
+ function createOpenSession(sessions) {
2064
+ return (id) => sessions.open(id);
2065
+ }
2066
+ /**
1468
2067
  * Wrap a session-bound `loadOlder` call into a safe action closure.
1469
2068
  *
1470
2069
  * - Missing binding: resolves (never throws on an unlisted/unscoped session).
@@ -1530,6 +2129,10 @@ window.__ModuleLoader__.load({
1530
2129
  "search.label": "搜索消息",
1531
2130
  /** aria-label on the bookmarks-only filter toggle. */
1532
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": "退出聚焦",
1533
2136
  /** aria-label on the hover tooltip star toggle. */
1534
2137
  "bookmark.star": "收藏此消息",
1535
2138
  /** aria-label on the search clear button. */
@@ -1573,7 +2176,25 @@ window.__ModuleLoader__.load({
1573
2176
  /** Collapse-turn tooltip action. */
1574
2177
  "collapse.turn": "折叠此轮",
1575
2178
  /** Expand-turn tooltip action. */
1576
- "expand.turn": "展开此轮"
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": "结果已截断,请细化关键词"
1577
2198
  };
1578
2199
  const en = {
1579
2200
  "jump.to": "Jump to message {n}",
@@ -1583,6 +2204,8 @@ window.__ModuleLoader__.load({
1583
2204
  "search.placeholder": "Search message content",
1584
2205
  "search.label": "Search messages",
1585
2206
  "bookmark.filter": "Bookmarks only",
2207
+ "focus.on": "Focus mode",
2208
+ "focus.off": "Exit focus",
1586
2209
  "bookmark.star": "Bookmark this message",
1587
2210
  "search.clear": "Clear search",
1588
2211
  "load.older": "Load older messages",
@@ -1604,7 +2227,16 @@ window.__ModuleLoader__.load({
1604
2227
  "copy.message": "Copy message",
1605
2228
  "fork.here": "Fork from here",
1606
2229
  "collapse.turn": "Collapse turn",
1607
- "expand.turn": "Expand 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"
1608
2240
  };
1609
2241
  //#endregion
1610
2242
  //#region src/client/index.ts
@@ -1641,7 +2273,9 @@ window.__ModuleLoader__.load({
1641
2273
  locale: "dsh-milestone",
1642
2274
  inject: (sessionId) => ({
1643
2275
  loadOlder: createLoadOlder(ctx.sessions, sessionId),
1644
- forkAt: createForkAt(ctx.sessions, sessionId)
2276
+ forkAt: createForkAt(ctx.sessions, sessionId),
2277
+ searchSessions: createSessionSearch(ctx.sessions),
2278
+ openSession: createOpenSession(ctx.sessions)
1645
2279
  })
1646
2280
  }, MilestoneRail));
1647
2281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-milestone",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Git-style milestone timeline for DeepSeek Harness: hover for metadata, click to jump to any message. 会话里程碑导航条:圆点时间线,定位并跳转到每条提问。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",