dsh-conversation-navigator 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -17,17 +17,17 @@ A **DeepSeek Harness (DSH) Web conversation navigator panel**: a turn-folded out
17
17
 
18
18
  Browser-only (no host behavior), plain JavaScript, zero build step, zero npm dependencies (buttons/tooltips reuse the official primitives from the DSH kernel seed).
19
19
 
20
- ![Full mode](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-main.png)
21
- ![Hidden mode](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-no-round.png)
22
- ![Minimal mode · collapsed](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-minimal-hide.png)
23
- ![Minimal mode · expanded](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-minimal-expand.png)
20
+ | Dark mode | Light mode |
21
+ | :---: | :---: |
22
+ | ![Dark mode](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/dark.png) | ![Light mode](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/light.png) |
24
23
 
25
- > Three forms: full, hidden, and minimal-left (minimal-right is shelved as of 0.2.6, code retained). The minimal strips collapse to the in-row indicator bars; hover expands the positioning panel. More screenshots in [assets/screenshots](./assets/screenshots). The two "community skins" shots are taken under third-party skins (whale-girl maid theme and the "Your Name" theme), not bundled with the plugin.
24
+ > The conversation navigator in dark and light mode. Three forms: full, hidden, and minimal-left (minimal-right is shelved as of 0.2.6, code retained). The minimal strips collapse to the in-row indicator bars; hover expands the positioning panel. More screenshots in [assets/screenshots](./assets/screenshots).
26
25
 
27
26
  ## Features
28
27
 
29
28
  - **Turn-folded outline**: shows "Turn N + your question" rows by default, long conversations at a glance
30
29
  - **Keyword filter**: click the top-left search icon to reveal the inline input; matches only your questions + the assistant's actual reply text (context, tool calls, commands, compaction and reasoning never match); hits are highlighted and the list text is windowed to the keyword
30
+ - **Jump straight to the hit**: clicking a search result scrolls to where the keyword actually appears in the conversation (not just the top of the message) and marks every occurrence inside that message — a brief flash that eases into a soft persistent tint, so long replies no longer need hunting
31
31
  - **Expand / collapse steps**: the arrow button on the right of each turn row (`▸ N`, rotates to `▾` when expanded) smoothly expands or collapses the turn's step details (assistant replies, tool calls, commands, compaction points, etc.)
32
32
  - **Hover full text**: rest the mouse on a turn row and a bubble shows the user's complete question, no longer truncated to one line
33
33
  - **Full / Hidden / Minimal-left modes**: the header toggle cycles three states —
@@ -96,6 +96,19 @@ Edit `lib/client.js` and restart `dsh web`. Docked state, dragged position, mini
96
96
  - **Version-sensitive points**: `[data-chat-anchor-key]` / `[data-conversation-scroll]` are the current DOM anchor conventions of the DSH chat view (verified still present in official v0.1.2-rc.1); if they change after a DSH upgrade, only `findAnchor` / `computeActiveKey` in `lib/client.js` need adjusting
97
97
  - No hard `timer` dependency: the client timer service is used for throttling when present, and degrades to unthrottled otherwise
98
98
 
99
+ ## Host ↔ plugin version
100
+
101
+ Official v0.1.2-rc.1 made a breaking change to the conversation data layer (conversation nodes moved from the `session.chat` snapshot to the `uiConversation` chat target snapshot), so the plugin forks by host version:
102
+
103
+ | Host `dsh-client-ui-conversation` | Matching plugin version | npm tag |
104
+ |---|---|---|
105
+ | **≥ 0.1.2-rc.1** (has the `uiConversation` service) | **0.2.6 and later** (currently 0.2.10) | `latest` (default) |
106
+ | **≤ 0.1.1-rc.2** (no `uiConversation`, legacy `session.chat` snapshot) | **0.2.5 and earlier** | `legacy` |
107
+
108
+ - New hosts can install the latest normally (`dsh plugin --profile web add dsh-conversation-navigator`).
109
+ - Legacy hosts should pin the legacy tag: `npm i dsh-conversation-navigator@legacy` (= 0.2.5).
110
+ - From 0.2.6 the `peerDependencies` range is `>=0.1.2-rc.1 <0.2.0` (new host only); 0.2.5 and earlier declare no such range.
111
+
99
112
  ## Structure
100
113
 
101
114
  ```
@@ -108,6 +121,17 @@ cordis.patch.yml # bundle patch layer (inserts the plugin row)
108
121
  example.patch.yml # patch example for manual installs
109
122
  ```
110
123
 
124
+ ## Traffic
125
+
126
+ Window **2026-08-17 → 2026-09-20** (refreshed daily by GitHub Actions):
127
+
128
+ | npm downloads | GitHub unique visitors | GitHub unique cloners | Stars | Forks |
129
+ | ---: | ---: | ---: | ---: | ---: |
130
+ | 6,206 | 540 | 335 | 23 | 1 |
131
+
132
+ - Details: [traffic-report.md](./docs/traffic/traffic-report.md) | raw archive: [traffic-archive.json](./docs/traffic/traffic-archive.json) | chart & docs: [docs/traffic](./docs/traffic/README.md)
133
+ - Caveats: npm downloads include mirrors, CI and reinstalls, with a 1–2 day settlement lag; GitHub unique visitors / cloners are closer to real user counts.
134
+
111
135
  ## License
112
136
 
113
137
  MIT
package/README.md CHANGED
@@ -17,17 +17,17 @@
17
17
 
18
18
  纯浏览器插件(无宿主行为)、纯 JavaScript、零构建、零 npm 依赖(按钮/Tooltip 复用 DSH 内核 seed 的官方 primitives)。
19
19
 
20
- ![显示轮次模式](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-main.png)
21
- ![隐藏轮次模式](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-no-round.png)
22
- ![极简模式·收起](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-minimal-hide.png)
23
- ![极简模式·展开](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/mode-minimal-expand.png)
20
+ | 深色模式 | 浅色模式 |
21
+ | :---: | :---: |
22
+ | ![深色模式](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/dark.png) | ![浅色模式](https://raw.githubusercontent.com/gjj-star/dsh-conversation-navigator/main/assets/modes/light.png) |
24
23
 
25
- > 三种形态:显示轮次、隐藏轮次、极简·左(极简·右已暂缓,代码保留在仓库中;极简收起仅露行内指示条,悬停展开定位面板)。完整截图见 [assets/screenshots](./assets/screenshots)。其中「社区皮肤适配」两张为第三方皮肤下的效果(鲸鱼娘女仆主题与君の名は主题),非插件自带。
24
+ > 深色 / 浅色模式下的会话导航面板。三种形态:显示轮次、隐藏轮次、极简·左(极简·右自 0.2.6 暂缓,代码保留;极简收起仅露行内指示条,悬停展开定位面板)。更多截图见 [assets/screenshots](./assets/screenshots)。
26
25
 
27
26
  ## 功能
28
27
 
29
28
  - **按轮折叠大纲**:默认只显示"第 N 轮 + 你的问题"列表,长对话一目了然
30
29
  - **关键词过滤**:点击左上角搜索图标呼出输入框,只按「你的提问 + 助手实际回复文本」过滤(上下文、工具、命令、压缩、推理等不参与匹配),命中词高亮,列表文本自动定位到关键词处
30
+ - **搜索命中直达**:点击命中的搜索结果,直接滚动到关键词在会话正文中出现的位置(不再只对齐消息顶部),并把该条消息内所有命中处标出来——先亮一下,再平滑淡出为常驻淡色,长回复里不用再自己找
31
31
  - **展开/折叠步次**:点击轮次行右侧的箭头按钮(`▸ N`,展开时旋转为 `▾`)平滑展开/折叠该轮的步次明细(助手回复、工具调用、命令、压缩点等)
32
32
  - **悬停查看全文**:鼠标在轮次行停留片刻,气泡显示该轮用户提问的完整原文,不再被单行截断
33
33
  - **显示 / 隐藏 / 极简·左三种形态**:头部切换按钮三态循环——「显示轮次」为经典分组视图(压缩等系统事件与轮次同级加粗展示)→「隐藏轮次」每行统一为轨迹徽标 + 文本(用户 = 业务蓝、助手 = 紫罗兰、压缩 = 中性灰)→「极简·左」锚定对话区左缘(为 dsh-better-sidebar 让出右侧,并与官方右侧轨道零重叠),再回到「显示轮次」;「极简·右」形态自 0.2.6 起暂缓(与官方 TurnNavigator 轨道视觉重叠),代码保留;
@@ -93,6 +93,19 @@ dsh plugin --profile web add ./dsh-conversation-navigator-<version>.tgz
93
93
  - **版本敏感点**:`[data-chat-anchor-key]` / `[data-conversation-scroll]` 是当前 DSH 聊天视图的 DOM 锚点约定(官方 v0.1.2-rc.1 中已验证仍在),DSH 升级后若锚点变化,只需调整 `lib/client.js` 中 `findAnchor` / `computeActiveKey` 两个函数
94
94
  - 未声明 `timer` 硬依赖:客户端的 timer 服务存在则用于节流,不存在时自动退化为未节流模式
95
95
 
96
+ ## 宿主版本 ↔ 插件版本
97
+
98
+ 官方 v0.1.2-rc.1 对会话数据层做了一次爆破性更新(会话节点从 `session.chat` 快照迁移到 `uiConversation` 的 chat target 快照),因此插件按宿主版本分叉:
99
+
100
+ | 宿主 `dsh-client-ui-conversation` | 对应插件版本 | npm 标签 |
101
+ |---|---|---|
102
+ | **≥ 0.1.2-rc.1**(有 `uiConversation` 服务) | **0.2.6 及以后**(当前 0.2.10) | `latest`(默认) |
103
+ | **≤ 0.1.1-rc.2**(无 `uiConversation`,旧 `session.chat` 快照) | **0.2.5 及以前** | `legacy` |
104
+
105
+ - 新宿主直接安装最新版即可(`dsh plugin --profile web add dsh-conversation-navigator`)。
106
+ - 旧宿主请指定旧标签:`npm i dsh-conversation-navigator@legacy`(= 0.2.5)。
107
+ - 0.2.6 起 `peerDependencies` 声明为 `>=0.1.2-rc.1 <0.2.0`(仅新宿主);0.2.5 及以前不声明该范围。
108
+
96
109
  ## 目录结构
97
110
 
98
111
  ```
@@ -105,6 +118,17 @@ cordis.patch.yml # 组合包补丁层(插入插件行)
105
118
  example.patch.yml # 手动安装时的补丁示例
106
119
  ```
107
120
 
121
+ ## 流量
122
+
123
+ 统计区间 **2026-08-17 → 2026-09-20**(GitHub Actions 每日自动刷新):
124
+
125
+ | npm 下载 | GitHub 独立访客 | GitHub 独立克隆者 | Stars | Forks |
126
+ | ---: | ---: | ---: | ---: | ---: |
127
+ | 6,206 | 540 | 335 | 23 | 1 |
128
+
129
+ - 明细报告:[traffic-report.md](./docs/traffic/traffic-report.md)|原始归档:[traffic-archive.json](./docs/traffic/traffic-archive.json)|趋势图与说明:[docs/traffic](./docs/traffic/README.md)
130
+ - 口径:npm 下载含镜像站、CI 与重复安装,且有 1–2 天结算延迟;GitHub 独立访客 / 独立克隆者更接近真实人数。
131
+
108
132
  ## License
109
133
 
110
134
  MIT
package/lib/client.js CHANGED
@@ -96,6 +96,9 @@ window.__ModuleLoader__.load({
96
96
  const CSS = `
97
97
  .cnvnav-panel {
98
98
  position: fixed; z-index: 9999; width: 288px;
99
+ /* 显式 border-box: 面板有 1px 边框, 若按默认 content-box, style.width(内容宽) 会与
100
+ offsetWidth/rect.width(含边框) 差 2px, 拖拽缩放的几何换算会因此逐帧漂移 */
101
+ box-sizing: border-box;
99
102
  display: flex; flex-direction: column;
100
103
  pointer-events: auto;
101
104
  background: var(--dsw-alias-bg-overlay, #fff);
@@ -229,6 +232,7 @@ window.__ModuleLoader__.load({
229
232
  background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 30%, transparent);
230
233
  color: inherit; border-radius: 2px; padding: 0 1px; font-weight: 600;
231
234
  }
235
+
232
236
  .cnvnav-empty { padding: 18px 10px; text-align: center; color: var(--dsw-alias-label-secondary, #888); }
233
237
  /* ---------- 极简模式 ---------- */
234
238
  .cnvnav-mini-wrap {
@@ -908,13 +912,223 @@ window.__ModuleLoader__.load({
908
912
  return null;
909
913
  }
910
914
  let lastActive = null;
911
- function jumpTo(key) {
915
+ /* ---------- 会话正文内的命中定位(点击搜索命中后把关键词标在正文里) ---------- */
916
+ const FIND_STEPS = 28; /* 淡出用的预置层数(层名 cnvnav-find-s0 … s27) */
917
+ const FIND_HOT_PCT = 55; /* 命中瞬间的背景浓度(%) */
918
+ const FIND_DIM_PCT = 22; /* 最终常驻的淡色浓度(%) */
919
+ const FIND_HOLD_MS = 300; /* 最亮保持时长 */
920
+ const FIND_FADE_MS = 900; /* 变淡时长(easeOutCubic 逐帧换层) */
921
+ const FIND_MAX_RANGES = 200;
922
+ const FIND_BLOCK_TAGS = { P: 1, DIV: 1, LI: 1, UL: 1, OL: 1, PRE: 1, BLOCKQUOTE: 1, TD: 1, TH: 1, TR: 1, TABLE: 1, SECTION: 1, ARTICLE: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1 };
923
+ let findRaf = null; /* 淡出动画句柄 */
924
+ let findStep = null; /* 当前注册的层号 */
925
+ let findMark = null; /* 当前高亮的 Highlight 对象(各层共用同一份 Range) */
926
+ function findLayerName(step) {
927
+ return "cnvnav-find-s" + step;
928
+ }
929
+ function findAlphaPct(step) {
930
+ const k = FIND_STEPS <= 1 ? 1 : step / (FIND_STEPS - 1);
931
+ return FIND_HOT_PCT + (FIND_DIM_PCT - FIND_HOT_PCT) * k;
932
+ }
933
+ /* ::highlight 伪元素不支持 transition/animation, 只能预置多层不同浓度逐帧切换 */
934
+ function findFadeCss() {
935
+ let out = "\n/* 会话正文内的命中高亮: 逐帧换层实现平滑淡出(::highlight 不支持 transition) */";
936
+ for (let i = 0; i < FIND_STEPS; i++) {
937
+ out += "\n::highlight(" + findLayerName(i) + ") {\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) " + findAlphaPct(i).toFixed(1) + "%, transparent);\n color: inherit;\n}";
938
+ }
939
+ return out;
940
+ }
941
+ (function injectFindFadeCss() {
942
+ try {
943
+ const el = document.createElement("style");
944
+ el.textContent = findFadeCss();
945
+ document.head.appendChild(el);
946
+ } catch (err) { console.error(err); }
947
+ })();
948
+ /* 模块顶部的 const CSS 是插件自己的样式字符串, 会遮蔽浏览器全局 CSS, 故走 globalThis */
949
+ function highlightApi() {
950
+ try {
951
+ const api = typeof globalThis === "undefined" ? undefined : globalThis.CSS;
952
+ if (api === undefined || api === null || api.highlights === undefined) return null;
953
+ return api.highlights;
954
+ } catch (err) { return null; }
955
+ }
956
+ function highlightCtor() {
957
+ try {
958
+ return typeof globalThis === "undefined" ? undefined : globalThis.Highlight;
959
+ } catch (err) { return undefined; }
960
+ }
961
+ function dropFindLayer() {
962
+ const store = highlightApi();
963
+ if (store !== null && findStep !== null) store.delete(findLayerName(findStep));
964
+ findStep = null;
965
+ }
966
+ function showFindStep(step) {
967
+ const store = highlightApi();
968
+ if (store === null || findMark === null) return;
969
+ const next = Math.max(0, Math.min(FIND_STEPS - 1, step));
970
+ if (next === findStep) return;
971
+ if (findStep !== null) store.delete(findLayerName(findStep));
972
+ store.set(findLayerName(next), findMark);
973
+ findStep = next;
974
+ }
975
+ function stopFindFade() {
976
+ if (findRaf !== null) {
977
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(findRaf);
978
+ findRaf = null;
979
+ }
980
+ }
981
+ function startFindFade() {
982
+ stopFindFade();
983
+ if (typeof requestAnimationFrame !== "function") { showFindStep(FIND_STEPS - 1); return; }
984
+ const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
985
+ const started = now();
986
+ const tick = () => {
987
+ const t = now() - started;
988
+ if (t <= FIND_HOLD_MS) showFindStep(0);
989
+ else if (t >= FIND_HOLD_MS + FIND_FADE_MS) { showFindStep(FIND_STEPS - 1); findRaf = null; return; }
990
+ else {
991
+ const u = (t - FIND_HOLD_MS) / FIND_FADE_MS;
992
+ const eased = 1 - Math.pow(1 - u, 3); /* easeOutCubic */
993
+ showFindStep(Math.round(eased * (FIND_STEPS - 1)));
994
+ }
995
+ findRaf = requestAnimationFrame(tick);
996
+ };
997
+ findRaf = requestAnimationFrame(tick);
998
+ }
999
+ function clearFindHighlight() {
1000
+ try {
1001
+ stopFindFade();
1002
+ dropFindLayer();
1003
+ findMark = null;
1004
+ } catch (err) { console.error(err); }
1005
+ }
1006
+ /* 最近的块级祖先: 跨块边界补一个分隔符, 避免两段文字被误判成一个词 */
1007
+ function findBlockOf(node) {
1008
+ let el = node.parentElement;
1009
+ while (el !== null && el !== undefined) {
1010
+ if (FIND_BLOCK_TAGS[el.tagName] === 1) return el;
1011
+ el = el.parentElement;
1012
+ }
1013
+ return null;
1014
+ }
1015
+ /* 收集锚点子树内可见的文本节点(跳过脚本/样式) */
1016
+ function collectFindTextNodes(root) {
1017
+ const out = [];
1018
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1019
+ acceptNode(node) {
1020
+ const text = node.nodeValue;
1021
+ if (text === null || text === "" || text.trim() === "") return NodeFilter.FILTER_REJECT;
1022
+ const parent = node.parentElement;
1023
+ if (parent === null || parent === undefined) return NodeFilter.FILTER_REJECT;
1024
+ const tag = parent.tagName;
1025
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT" || tag === "TEXTAREA") return NodeFilter.FILTER_REJECT;
1026
+ return NodeFilter.FILTER_ACCEPT;
1027
+ },
1028
+ });
1029
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) out.push(node);
1030
+ return out;
1031
+ }
1032
+ /* 归一化索引: 连续空白折叠成单个空格; map[i] = [文本节点, 节点内偏移] */
1033
+ function buildFindIndex(root) {
1034
+ const nodes = collectFindTextNodes(root);
1035
+ let text = "";
1036
+ const map = [];
1037
+ let prevBlock = null;
1038
+ let pendingSpace = false;
1039
+ for (const node of nodes) {
1040
+ const value = node.nodeValue;
1041
+ const block = findBlockOf(node);
1042
+ if (text !== "" && (block === null || block !== prevBlock)) pendingSpace = true;
1043
+ prevBlock = block;
1044
+ for (let i = 0; i < value.length; i++) {
1045
+ const ch = value.charAt(i);
1046
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v") { pendingSpace = text !== ""; continue; }
1047
+ if (pendingSpace) { text += " "; map.push([node, i]); pendingSpace = false; }
1048
+ text += ch;
1049
+ map.push([node, i]);
1050
+ }
1051
+ }
1052
+ return { text, map };
1053
+ }
1054
+ function findNeedle(kw) {
1055
+ return String(kw).replace(/\s+/g, " ").trim().toLowerCase();
1056
+ }
1057
+ /* 关键词在 DOM 里的每一处出现 → Range(最多 FIND_MAX_RANGES 处) */
1058
+ function findKeywordRanges(root, needle) {
1059
+ const ranges = [];
1060
+ if (needle === "") return ranges;
1061
+ const index = buildFindIndex(root);
1062
+ if (index.text === "") return ranges;
1063
+ let hay = index.text.toLowerCase();
1064
+ if (hay.length !== index.text.length) hay = index.text; /* 极端 Unicode 大小写长度变化时退回原串 */
1065
+ let from = 0;
1066
+ for (;;) {
1067
+ const at = hay.indexOf(needle, from);
1068
+ if (at === -1) break;
1069
+ const start = index.map[at];
1070
+ const end = index.map[at + needle.length - 1];
1071
+ if (start !== undefined && end !== undefined) {
1072
+ try {
1073
+ const range = document.createRange();
1074
+ range.setStart(start[0], start[1]);
1075
+ range.setEnd(end[0], end[1] + 1);
1076
+ ranges.push(range);
1077
+ } catch (err) { /* 节点已被宿主重渲染替换: 跳过这一处 */ }
1078
+ }
1079
+ from = at + needle.length;
1080
+ if (ranges.length >= FIND_MAX_RANGES) break;
1081
+ }
1082
+ return ranges;
1083
+ }
1084
+ /* 把首个命中滚到视口偏上处(而不是消息顶部) */
1085
+ function scrollRangeIntoView(range) {
1086
+ try {
1087
+ const rect = range.getBoundingClientRect();
1088
+ const scrollport = document.querySelector("[data-conversation-scroll]");
1089
+ if (scrollport !== null && scrollport !== undefined) {
1090
+ const box = scrollport.getBoundingClientRect();
1091
+ const delta = rect.top - box.top - Math.max(72, box.height * 0.32);
1092
+ if (typeof scrollport.scrollBy === "function") scrollport.scrollBy({ top: delta, behavior: "smooth" });
1093
+ else scrollport.scrollTop += delta;
1094
+ return;
1095
+ }
1096
+ const host = range.startContainer;
1097
+ const el = host.nodeType === 1 ? host : host.parentElement;
1098
+ if (el !== null && el !== undefined && typeof el.scrollIntoView === "function") el.scrollIntoView({ behavior: "smooth", block: "center" });
1099
+ } catch (err) { console.error(err); }
1100
+ }
1101
+ /* 正文内定位并高亮: 先最亮, 再 easeOut 逐帧变淡到常驻淡色; 返回是否成功 */
1102
+ function locateInConversation(row, kw) {
1103
+ clearFindHighlight();
1104
+ try {
1105
+ const store = highlightApi();
1106
+ const Ctor = highlightCtor();
1107
+ if (store === null || typeof Ctor !== "function") return false;
1108
+ const needle = findNeedle(kw);
1109
+ const ranges = findKeywordRanges(row, needle);
1110
+ if (ranges.length === 0) return false;
1111
+ findMark = new Ctor(...ranges);
1112
+ showFindStep(0);
1113
+ startFindFade();
1114
+ scrollRangeIntoView(ranges[0]);
1115
+ return true;
1116
+ } catch (err) {
1117
+ console.error(err);
1118
+ clearFindHighlight();
1119
+ return false;
1120
+ }
1121
+ }
1122
+ function jumpTo(key, kw) {
912
1123
  try {
913
1124
  const el = findAnchor(key);
914
1125
  if (el === null) return false;
915
- el.scrollIntoView({ behavior: "smooth", block: "start" });
916
1126
  lastActive = key;
917
1127
  setState({ activeKey: key });
1128
+ /* 搜索命中: 直接定位到关键词所在处并在正文里高亮; 其余情况退回消息顶部 */
1129
+ if (typeof kw === "string" && kw !== "" && locateInConversation(el, kw)) return true;
1130
+ clearFindHighlight();
1131
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
918
1132
  return true;
919
1133
  } catch (err) {
920
1134
  console.error(err);
@@ -1092,6 +1306,7 @@ window.__ModuleLoader__.load({
1092
1306
  }
1093
1307
  function toggleSearch() {
1094
1308
  if (searchOpen) setFilter("");
1309
+ clearFindHighlight();
1095
1310
  setSearchOpen(!searchOpen);
1096
1311
  }
1097
1312
  /* ---------- 拖动定位(仅面板形式, 极简条不参与) ---------- */
@@ -1156,11 +1371,16 @@ window.__ModuleLoader__.load({
1156
1371
  if (resizeStart !== null) return;
1157
1372
  const el = panelEl;
1158
1373
  if (el === null) return;
1374
+ /* 锚定拖动开始时的右边缘/上边缘: 之后每帧都基于它换算 left/高度上限。
1375
+ 绝不能用实时 rect 反推(老实现如此), 否则「请求宽度 ≠ 实际渲染宽度」的
1376
+ 差值会逐帧累积, 表现为任何方向的鼠标位移都把面板往右推。 */
1377
+ const rect = el.getBoundingClientRect();
1159
1378
  resizeStart = {
1160
1379
  startX: e.clientX, startY: e.clientY,
1161
1380
  baseW: el.offsetWidth || 288,
1162
1381
  baseH: el.offsetHeight || 200,
1163
- baseMaxH: (function () { const v = parseInt(el.style.maxHeight, 10); return Number.isFinite(v) && v > 0 ? v : (el.offsetHeight || 200); })(),
1382
+ anchorRight: rect.right,
1383
+ anchorTop: rect.top,
1164
1384
  el: el,
1165
1385
  };
1166
1386
  try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ }
@@ -1175,7 +1395,6 @@ window.__ModuleLoader__.load({
1175
1395
  以拖拽起点为基准线性跟随鼠标(无漂移/无状态跳变):
1176
1396
  拖小立即压缩出滚动, 拖大在内容充足时线性恢复更多行;
1177
1397
  内容不足时面板保持内容高(拖动无感, 属预期)。 */
1178
- const r = d.el.getBoundingClientRect();
1179
1398
  const sp = document.querySelector("[data-conversation-scroll]");
1180
1399
  /* 上限必须与 place() 各分支一致, 否则松手后会被 place 压回造成跳变:
1181
1400
  docked/常规 = min(容器高-24, vh-40); 自由位置 = vh-顶-8 */
@@ -1183,14 +1402,15 @@ window.__ModuleLoader__.load({
1183
1402
  const cap = Math.max(minPanelH(), Math.round(
1184
1403
  state.docked && sr !== null
1185
1404
  ? Math.min(sr.height - 24, vh - 40)
1186
- : vh - r.top - 8
1405
+ : vh - d.anchorTop - 8
1187
1406
  ));
1188
1407
  const nw = Math.min(Math.max(240, d.baseW - dx), 560);
1189
1408
  /* 目标上限 = 起点可见高 + 总位移, 线性且可逆 */
1190
1409
  const nMaxH = Math.min(Math.max(minPanelH(), d.baseH + dy), cap);
1191
- const left = Math.max(8, r.right - nw);
1410
+ /* 右边缘钉在起点位置(而不是实时 rect): 拖动过程中面板绝不平移 */
1411
+ const left = Math.max(8, Math.round(d.anchorRight - nw));
1192
1412
  d.el.style.width = Math.round(nw) + "px";
1193
- d.el.style.left = Math.round(left) + "px";
1413
+ d.el.style.left = left + "px";
1194
1414
  d.el.style.height = "";
1195
1415
  d.el.style.maxHeight = Math.round(nMaxH) + "px";
1196
1416
  }
@@ -1220,6 +1440,10 @@ window.__ModuleLoader__.load({
1220
1440
  const panel = panelEl;
1221
1441
  if (panel === null) return false;
1222
1442
  if (dragMove !== null) return true;
1443
+ /* 缩放中同样不接管: place() 会按"持久化的旧宽高"重写 width/left/maxHeight,
1444
+ 若在拖拽缩放过程中触发(400ms 复查 / ResizeObserver / window.resize),
1445
+ 就会把用户正在拖的几何值顶掉。 */
1446
+ if (resizeStart !== null) return true;
1223
1447
  const vw = document.documentElement.clientWidth || 1000;
1224
1448
  const vh = document.documentElement.clientHeight || 800;
1225
1449
  /* 新宿主 dock 右侧需让出官方 rail 的 28px 竖带(right: calc(12px - clearance) + width:28px),
@@ -1492,7 +1716,7 @@ window.__ModuleLoader__.load({
1492
1716
  ref: (el) => { itemEls[item.key] = el; },
1493
1717
  className: "cnvnav-item" + (item.key === snap.activeKey ? " cnvnav-item-active" : "") + (staggerMs !== undefined ? " cnvnav-fade-item" : ""),
1494
1718
  style: staggerMs !== undefined ? { animationDelay: staggerMs + "ms" } : undefined,
1495
- onClick: () => jumpTo(item.key),
1719
+ onClick: () => jumpTo(item.key, filtering && hit(item.search) ? kw : null),
1496
1720
  },
1497
1721
  React.createElement("span", { className: "cnvnav-badge cnvnav-badge-" + item.cls }, item.label),
1498
1722
  hasPreview
@@ -1509,6 +1733,8 @@ window.__ModuleLoader__.load({
1509
1733
  const useSnippet = filtering && hit(headSearch);
1510
1734
  const sub = useSnippet ? snippetNodes(headSearch, kw, 5, 55) : g.subtitle;
1511
1735
  const hasSub = sub !== "" && (!Array.isArray(sub) || sub.length > 0);
1736
+ /* 组头自身命中时, 点击同样在正文里定位关键词 */
1737
+ const headKw = filtering && g.headIsUser && g.items.length > 0 && hit(g.items[0].search) ? kw : null;
1512
1738
  /* 标题策略:
1513
1739
  显示轮次: 轮次行 = 加粗「第 N 轮」; 压缩等非轮次行 = 加粗类型标签, 视作轮次同级
1514
1740
  隐藏轮次: 每行统一轨迹徽标 — 轮次头 = 「用户」业务蓝(助手开头则助手紫), 非轮次行 = 各自徽标(压缩=中性灰) */
@@ -1545,7 +1771,7 @@ window.__ModuleLoader__.load({
1545
1771
  React.createElement("button", {
1546
1772
  type: "button",
1547
1773
  className: "cnvnav-group-head",
1548
- onClick: () => jumpTo(g.headKey),
1774
+ onClick: () => jumpTo(g.headKey, headKw),
1549
1775
  },
1550
1776
  titleNode,
1551
1777
  hasSub
@@ -1690,7 +1916,7 @@ window.__ModuleLoader__.load({
1690
1916
  placeholder: "请输入关键词",
1691
1917
  value: filter,
1692
1918
  autoFocus: true,
1693
- onChange: (e) => setFilter(e.target.value),
1919
+ onChange: (e) => { clearFindHighlight(); setFilter(e.target.value); },
1694
1920
  })
1695
1921
  : null,
1696
1922
  searchOpen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-navigator",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "DSH(DeepSeek Harness)会话导航插件:按轮折叠的对话大纲面板,支持节点点击跳转、滚动位置高亮与轨迹视图同款配色。",
5
5
  "keywords": [
6
6
  "dsh",