dsh-quick-toc 0.2.0-beta → 0.3.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.
package/README.en.md CHANGED
@@ -2,18 +2,28 @@
2
2
 
3
3
  > **English** | [中文](README.md)
4
4
 
5
- A quick conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH): extracts Markdown headings (H1–H6) from AI replies into a navigable outline panel, grouped by conversation turn, with auto-follow highlighting.
5
+ A quick conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH): extracts Markdown headings (H1–H6) from AI replies into a navigable outline panel, grouped by conversation turn, with auto-follow highlighting, keyword search and in-chat match highlighting.
6
6
 
7
7
  ## Features
8
8
 
9
- - **Turn-grouped outline** — each user message + its AI replies form one group, with the group's end time as the header
9
+ - **Turn-grouped outline** — each user message + its AI replies form one group, with the group's end time as the header (plus the turn's first-line preview; click it to jump to the turn start)
10
+ - **Keyword search** — a magnifier in the header opens the search box; Enter cycles matches (n/N counter), Esc closes; toggle between **title / full-text** scope
11
+ - **In-chat highlighting** — matched keywords are highlighted in the conversation; the current match gets a distinct highlight and is scrolled to the upper-middle of the viewport; every occurrence counts toward n/N
10
12
  - **Auto-follow highlight** — as you scroll the conversation, the turns visible in the viewport light up in the outline (multiple at once); the outline auto-loads and scrolls to keep them visible
11
13
  - **Smooth jump** — click a heading to glide to the exact heading position in the conversation
12
14
  - **Dockable & resizable** — dock left or right (drag the top bar), resize from the edges/corner, collapse to a draggable edge handle; panel size/position remembered
13
- - **Scrollbar follows the dock** — scrollbar sits on the left when docked left, right when docked right
14
15
  - **Paged rendering** — shows the latest groups first; scrolling to the top of the outline loads older ones
15
16
  - **Markdown-aware titles** — inline `**bold**`, *italic*, `` `code` ``, `[links](url)`, `~~strike~~` are stripped from heading text
16
- - Auto-hides when the conversation has no headings; works in light/dark themes
17
+ - Auto-hides when the conversation has no headings; works in light/dark themes; the inner-shadow card look adapts to the theme (white bevel in dark)
18
+
19
+ ## Compatibility
20
+
21
+ | Plugin | DSH |
22
+ | --- | --- |
23
+ | **0.3.0** | **≥ 0.1.2-rc.1** (new slot architecture) |
24
+ | 0.2.2 | DSH versions before 0.1.2-rc.1 |
25
+
26
+ 0.3.0 re-targeted the host integration (new slot architecture + `useChat` session data) and **only supports DSH 0.1.2-rc.1 and above**; for older DSH builds install 0.2.2. On install/update the DSH market pre-flights host compatibility from `dsh.compatibility.dshReleases` and `peerDependencies` in package.json and warns on a mismatch.
17
27
 
18
28
  ## Install
19
29
 
@@ -47,9 +57,10 @@ Restart DSH (double-click `restart-dsh.bat` on Windows) and open the Web UI. The
47
57
  ## Development
48
58
 
49
59
  - `lib/client.js` — all UI logic (browser side)
50
- - `lib/index.js` — host-side no-op entry
60
+ - `lib/index.js` — host-side no-op entry (extend with a version gate if needed)
51
61
  - `cordis.patch.yml` — loader patch (official DSH bundle format)
52
- - Client changes hot-load after a browser hard refresh; loader/patch changes need a DSH restart
62
+ - **Since 0.3.0 the plugin targets the new plugin model**: the panel registers into the session-scoped `conversation.input.overlay` slot (session hooks are contributed via `ctx.uiSession.provide`, `useChat` by ui-chat), and the panel body `createPortal`s to `document.body` as a fixed floating dock; data comes from `props.useChat` (`ChatSnapshot.order` + `nodes`; node shape: `kind: user/assistant-step`, `location.turn`, `data.blocks`)
63
+ - Client changes need a DSH restart (boot rev is content-based)
53
64
 
54
65
  ## License
55
66
 
package/README.md CHANGED
@@ -2,18 +2,28 @@
2
2
 
3
3
  > [English](README.en.md) | **中文**
4
4
 
5
- **DeepSeek Harness(DSH)对话大纲插件**:把 AI 回复中的 Markdown 标题(H1–H6)提取成可导航的大纲面板,按对话回合分组,自动跟随阅读位置。
5
+ **DeepSeek Harness(DSH)对话大纲插件**:把 AI 回复中的 Markdown 标题(H1–H6)提取成可导航的大纲面板,按对话回合分组,自动跟随阅读位置,支持关键字搜索与对话内高亮定位。
6
6
 
7
7
  ## 功能
8
8
 
9
- - **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组,组头显示该组最后一条消息的结束时间
9
+ - **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组,组头显示该组最后一条消息的结束时间(附带该回合首行预览,点击可跳到回合开头)
10
+ - **关键字搜索** —— 标题栏放大镜打开搜索框,回车逐个定位(n/N 计数),Esc 关闭;支持 **标题 / 全文** 范围切换
11
+ - **对话内高亮** —— 命中关键字在对话中高亮,当前命中单独标亮并滚动到视口上方约 1/3 处;同一条消息内的多次命中都计入 n/N
10
12
  - **自动跟随高亮** —— 滚动对话框时,视口内可见的回合在大纲中自动点亮(多组可同时点亮);大纲自动加载并滚动,保证正在读的组始终可见
11
13
  - **平滑跳转** —— 点击标题平滑滚动到对话中该标题的准确位置
12
14
  - **可停靠、可缩放** —— 左右停靠(拖顶部横条移动)、边缘/角部拖拽调整大小、收起成可拖动的边缘把手;大小与位置自动记忆
13
- - **滚动条跟随停靠方向** —— 停靠左侧时滚动条在左,停靠右侧时在右
14
15
  - **分页渲染** —— 默认显示最近几组,大纲滚动到顶部自动加载更早的组
15
16
  - **Markdown 清理** —— 标题中的 `**加粗**`、*斜体*、`` `代码` ``、`[链接](url)`、`~~删除线~~` 等行内标记自动剥离
16
- - 对话无标题时自动隐藏;适配深色 / 浅色主题
17
+ - 对话无标题时自动隐藏;适配深色 / 浅色主题;内阴影卡片质感随主题切换(深色为白色高光)
18
+
19
+ ## 兼容性
20
+
21
+ | 插件版本 | DSH 版本 |
22
+ | --- | --- |
23
+ | **0.3.0** | **≥ 0.1.2-rc.1**(新 slot 架构) |
24
+ | 0.2.2 | 0.1.2-rc.1 之前的旧版 DSH |
25
+
26
+ 0.3.0 重写了与宿主的对接(新版 slot 架构 + `useChat` 会话数据),**只支持 DSH 0.1.2-rc.1 及以上**;旧版 DSH 请安装 0.2.2。安装/更新时 DSH 市场会根据 package.json 的 `dsh.compatibility.dshReleases` 与 `peerDependencies` 做 host 兼容预检,版本不符会给出提示。
17
27
 
18
28
  ## 安装
19
29
 
@@ -47,9 +57,10 @@ dsh plugin --profile web add <本目录路径>
47
57
  ## 开发
48
58
 
49
59
  - `lib/client.js` —— 全部 UI 逻辑(浏览器端)
50
- - `lib/index.js` —— 宿主端空入口
60
+ - `lib/index.js` —— 宿主端空入口(版本守卫可在此扩展)
51
61
  - `cordis.patch.yml` —— loader patch(符合官方 bundle 规范)
52
- - client.js 后浏览器硬刷新即可生效;改 loader / patch 需重启 DSH
62
+ - **0.3.0 起对接新版插件模型**:面板注册进会话级 `conversation.input.overlay` 槽(会话级 hook `ctx.uiSession.provide` 贡献,`useChat` 由 ui-chat 贡献),面板本体 `createPortal` 到 `document.body` 渲染成固定浮层;数据入口为 `props.useChat`(`ChatSnapshot.order` + `nodes`,节点形状:`kind: user/assistant-step`、`location.turn`、`data.blocks`)
63
+ - 改 client.js 后需重启 DSH(boot rev 按内容算)
53
64
 
54
65
  ## License
55
66
 
package/lib/client.js CHANGED
@@ -6,11 +6,16 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
- require("@deepseek-ai/dsh-client-runtime/client");
9
+ let react_dom = require("react-dom"); // createPortal -> panel stays a top-level overlay (highest pointer priority)
10
+ // DSH 0.1.2-rc.1: the old @deepseek-ai/dsh-client-runtime package is gone.
11
+ // Runtime hooks (useChat etc.) now arrive as session-scope slot props —
12
+ // no runtime package to require here anymore.
10
13
 
11
14
  // ---------- constants ----------
12
15
  var PANEL_WIDTH = 288;
13
- var Z_BASE = 950;
16
+ // Very high base so the portal-rendered panel (top-level stacking context)
17
+ // always sits above DSH content and its transcript width handles.
18
+ var Z_BASE = 2200;
14
19
  var EASE = "cubic-bezier(0.22, 0.9, 0.3, 1)"; // smooth non-linear slide
15
20
 
16
21
  // ---------- markdown helpers ----------
@@ -135,6 +140,18 @@ window.__ModuleLoader__.load({
135
140
  return null;
136
141
  }
137
142
 
143
+ // count every occurrence of q (case-insensitive) inside text
144
+ function countOccurrences(text, q) {
145
+ var lower = text.toLowerCase();
146
+ var count = 0;
147
+ var idx = 0;
148
+ while ((idx = lower.indexOf(q, idx)) !== -1) {
149
+ count++;
150
+ idx += q.length;
151
+ }
152
+ return count;
153
+ }
154
+
138
155
  // find the conversation's "load older messages" button (scoped to the
139
156
  // conversation scrollport so panel buttons are never matched)
140
157
  function findLoadOlderButton() {
@@ -148,6 +165,58 @@ window.__ModuleLoader__.load({
148
165
  return null;
149
166
  }
150
167
 
168
+ // ---- search keyword highlight in the conversation ----
169
+ var highlightSpans = [];
170
+
171
+ function clearHighlights() {
172
+ for (var i = 0; i < highlightSpans.length; i++) {
173
+ var s = highlightSpans[i];
174
+ if (s.parentNode) {
175
+ var t = document.createTextNode(s.textContent);
176
+ s.parentNode.replaceChild(t, s);
177
+ }
178
+ }
179
+ highlightSpans = [];
180
+ }
181
+
182
+ // wrap every occurrence of q (case-insensitive) inside row's text nodes;
183
+ // the occurrence at `currentOcc` gets a distinct "current" highlight
184
+ function highlightRow(row, q, currentOcc) {
185
+ if (!row || !q) return;
186
+ var walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null);
187
+ var textNodes = [];
188
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
189
+ var occ = 0;
190
+ for (var i = 0; i < textNodes.length; i++) {
191
+ var node = textNodes[i];
192
+ var text = node.nodeValue;
193
+ if (!text) continue;
194
+ var lower = text.toLowerCase();
195
+ var idx = lower.indexOf(q);
196
+ if (idx < 0) continue;
197
+ var frag = document.createDocumentFragment();
198
+ var before = text.slice(0, idx);
199
+ if (before) frag.appendChild(document.createTextNode(before));
200
+ var mark = document.createElement("span");
201
+ if (occ === currentOcc) {
202
+ mark.className = "dqt-current";
203
+ mark.style.background = "rgba(79,140,255,0.55)";
204
+ mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
205
+ } else {
206
+ mark.style.background = "rgba(79,140,255,0.32)";
207
+ }
208
+ mark.style.borderRadius = "2px";
209
+ mark.style.color = "inherit";
210
+ mark.textContent = text.slice(idx, idx + q.length);
211
+ frag.appendChild(mark);
212
+ highlightSpans.push(mark);
213
+ occ++;
214
+ var after = text.slice(idx + q.length);
215
+ if (after) frag.appendChild(document.createTextNode(after));
216
+ node.parentNode.replaceChild(frag, node);
217
+ }
218
+ }
219
+
151
220
  // ---------- theme colors: DSH CSS variables (adapt to light/dark) ----------
152
221
  var C = {
153
222
  panelBg: "var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96))",
@@ -159,6 +228,42 @@ window.__ModuleLoader__.load({
159
228
  chip: "rgba(79, 140, 255, 0.18)"
160
229
  };
161
230
 
231
+ // ---- theme scheme detection (for the panel's 3D inner shadow) ----
232
+ // Prefer a positive dark signal from the OS preference, then DSH's theme
233
+ // attribute/class, then the --dsw-alias-bg-base luminance as a last resort.
234
+ // Failed-open to light so a light card never gets a dark inner shadow by
235
+ // mistake; the caller flips to the white bevel only on a confident dark read.
236
+ function isDarkScheme() {
237
+ try {
238
+ // DSH's authoritative dark marker: <body data-ds-dark-theme> is set when
239
+ // the dark scheme is active (system OR manual). Check it first.
240
+ var body = document.body;
241
+ if (body) {
242
+ if (body.hasAttribute && body.hasAttribute("data-ds-dark-theme")) return true;
243
+ var cls = body.className || "";
244
+ if (/dark|theme-dark/i.test(cls)) return true;
245
+ var attr = (body.getAttribute("data-theme") || body.getAttribute("data-color-scheme") || "").toLowerCase();
246
+ if (attr === "dark" || /dark/.test(attr)) return true;
247
+ }
248
+ if (typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches) return true;
249
+ var root = body || document.documentElement;
250
+ var cs = getComputedStyle(root).colorScheme;
251
+ if (typeof cs === "string" && /dark/i.test(cs)) return true;
252
+ var v = getComputedStyle(root).getPropertyValue("--dsw-alias-bg-base");
253
+ var m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(v);
254
+ if (m) return (Number(m[1]) + Number(m[2]) + Number(m[3])) < 384;
255
+ } catch (e) {}
256
+ return false;
257
+ }
258
+ // Inner shadow: computed per-render (not once) so it follows the live theme.
259
+ // Dark scheme -> soft WHITE bevel (recessed card highlight); light -> soft
260
+ // dark shade. Outer shadow stays off (clip-path would clip it).
261
+ function innerShadow() {
262
+ return isDarkScheme()
263
+ ? "inset 0 0 0 1px rgba(255,255,255,0.16), inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -10px 16px rgba(255,255,255,0.08)"
264
+ : "inset 0 0 0 1px rgba(0,0,0,0.08), inset 0 2px 10px rgba(0,0,0,0.18)";
265
+ }
266
+
162
267
  // ---------- error boundary ----------
163
268
  var ErrorBoundary = (function () {
164
269
  if (!react.Component) return function (p) { return p.children; };
@@ -198,13 +303,17 @@ window.__ModuleLoader__.load({
198
303
 
199
304
  // ---------- component ----------
200
305
  function OutlinePanel(props) {
201
- var useSession = props.useSession;
202
- if (!useSession) {
203
- console.warn("[dsh-quick-toc] useSession prop missing");
306
+ // DSH 0.1.2-rc.1: the conversation moved out of the session snapshot —
307
+ // it is now the session-scope `chat` hook (ChatSnapshot: order + nodes
308
+ // map + legacy projection), contributed by dsh-client-ui-chat. Same node
309
+ // shape as before (kind user/assistant-step, location.turn, data.blocks).
310
+ var useChat = props.useChat;
311
+ if (!useChat) {
312
+ console.warn("[dsh-quick-toc] useChat prop missing (requires DSH >= 0.1.2-rc.1)");
204
313
  return null;
205
314
  }
206
- var order = useSession(function (s) { return s.chat.order; });
207
- var nodes = useSession(function (s) { return s.chat.nodes; });
315
+ var order = useChat(function (s) { return s.order; });
316
+ var nodes = useChat(function (s) { return s.nodes; });
208
317
 
209
318
  // ---- hooks (ALL before any conditional return) ----
210
319
  // collapsed by default; user expands via the edge handle (default dock: left)
@@ -343,6 +452,22 @@ window.__ModuleLoader__.load({
343
452
  var searching = _s17[0];
344
453
  var setSearching = _s17[1];
345
454
  var searchTimerRef = react.useRef(null);
455
+ // search scope: "title" (default) or "full" (user messages + AI replies)
456
+ var _s18 = react.useState("title");
457
+ var searchScope = _s18[0];
458
+ var setSearchScope = _s18[1];
459
+ // previous scope kept briefly so the old label cross-fades with the new one
460
+ var _s19 = react.useState(null);
461
+ var prevScope = _s19[0];
462
+ var setPrevScope = _s19[1];
463
+ var scopeTimerRef = react.useRef(null);
464
+ var toggleScope = function () {
465
+ setPrevScope(searchScope);
466
+ setSearchScope(searchScope === "title" ? "full" : "title");
467
+ setMatchIdx(0);
468
+ if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
469
+ scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
470
+ };
346
471
  var openSearch = function () {
347
472
  setSearchOpen(true);
348
473
  setSearchAnim("enter");
@@ -350,6 +475,7 @@ window.__ModuleLoader__.load({
350
475
  var closeSearch = function () {
351
476
  setSearchAnim("out");
352
477
  setQuery("");
478
+ clearHighlights();
353
479
  setTimeout(function () {
354
480
  setSearchOpen(false);
355
481
  setSearchAnim("hidden");
@@ -369,6 +495,7 @@ window.__ModuleLoader__.load({
369
495
  setQuery(v);
370
496
  setMatchIdx(0);
371
497
  setSearching(true);
498
+ clearHighlights();
372
499
  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
373
500
  searchTimerRef.current = setTimeout(function () { setSearching(false); }, 220);
374
501
  };
@@ -545,9 +672,10 @@ window.__ModuleLoader__.load({
545
672
  var turnTimes = {};
546
673
  var turnUserKey = {};
547
674
  var turnUserText = {};
675
+ var turnUserFull = {};
548
676
  if (!order || !nodes) return result;
549
677
  // first pass: per-turn time — the LAST message of the turn wins (end time);
550
- // also remember each turn's user message key + first-line preview
678
+ // also remember each turn's user message key + first-line preview + full text
551
679
  for (var i = 0; i < order.length; i++) {
552
680
  var k0 = order[i];
553
681
  var n0 = nodes.get(k0);
@@ -558,11 +686,12 @@ window.__ModuleLoader__.load({
558
686
  if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
559
687
  turnUserKey[tid0] = k0;
560
688
  turnUserText[tid0] = previewText(extractUserText(n0), 30);
689
+ turnUserFull[tid0] = extractUserText(n0);
561
690
  }
562
691
  var t0 = getNodeTime(n0);
563
692
  if (t0) turnTimes[tid0] = t0;
564
693
  }
565
- // second pass: group assistant headings by turn
694
+ // second pass: group assistant headings + full reply texts by turn
566
695
  for (var j = 0; j < order.length; j++) {
567
696
  var key = order[j];
568
697
  var node = nodes.get(key);
@@ -576,11 +705,15 @@ window.__ModuleLoader__.load({
576
705
  time: turnId !== null ? (turnTimes[turnId] || "") : "",
577
706
  userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
578
707
  userText: turnId !== null ? (turnUserText[turnId] || "") : "",
708
+ userFull: turnId !== null ? (turnUserFull[turnId] || "") : "",
709
+ msgs: [],
579
710
  headings: []
580
711
  };
581
712
  result.push(current);
582
713
  }
583
- var parsed = parseHeadings(extractReplyText(node));
714
+ var text = extractReplyText(node);
715
+ current.msgs.push({ key: key, text: text });
716
+ var parsed = parseHeadings(text);
584
717
  var hIdx = 0;
585
718
  for (var k = 0; k < parsed.length; k++) {
586
719
  current.headings.push({ level: parsed[k].level, title: parsed[k].title, key: key, idx: hIdx });
@@ -661,7 +794,11 @@ window.__ModuleLoader__.load({
661
794
  return function () { sp.removeEventListener("scroll", update); };
662
795
  }, [keyToGroup]);
663
796
 
664
- // ---- search matches: headings whose title contains the query ----
797
+ // ---- search matches ----
798
+ // "title" scope: heading titles only; "full" scope: also the user
799
+ // message and every AI reply text of each turn.
800
+ // Every occurrence counts (multiple hits inside one message = multiple
801
+ // matches), so the n/N counter reflects the real total.
665
802
  var matches = react.useMemo(function () {
666
803
  var q = query.trim().toLowerCase();
667
804
  if (!q) return [];
@@ -670,13 +807,30 @@ window.__ModuleLoader__.load({
670
807
  var g = groups[gi];
671
808
  for (var j = 0; j < g.headings.length; j++) {
672
809
  var h = g.headings[j];
673
- if (h.title.toLowerCase().indexOf(q) >= 0) {
810
+ var n = countOccurrences(h.title, q);
811
+ for (var c = 0; c < n; c++) {
674
812
  out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
675
813
  }
676
814
  }
815
+ if (searchScope === "full") {
816
+ if (g.userKey && g.userFull) {
817
+ var nu = countOccurrences(g.userFull, q);
818
+ for (var cu = 0; cu < nu; cu++) {
819
+ out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
820
+ }
821
+ }
822
+ for (var m = 0; m < g.msgs.length; m++) {
823
+ var msg = g.msgs[m];
824
+ if (!msg.text) continue;
825
+ var nm = countOccurrences(msg.text, q);
826
+ for (var cm = 0; cm < nm; cm++) {
827
+ out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
828
+ }
829
+ }
830
+ }
677
831
  }
678
832
  return out;
679
- }, [groups, query]);
833
+ }, [groups, query, searchScope]);
680
834
 
681
835
  // ---- nothing to show without headings ----
682
836
  if (groups.length === 0) return null;
@@ -684,24 +838,20 @@ window.__ModuleLoader__.load({
684
838
  // ---- geometry: always position by `left` (px) so dock-switch & slide share one animated transition
685
839
  var dockRight = dock === "right";
686
840
  var panelLeft;
687
- if (open) {
688
- panelLeft = dockRight
689
- ? window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW
690
- : (viewport ? viewport.left + 8 : 8);
841
+ if (dockRight) {
842
+ // RIGHT dock: keep the panel's VISIBLE right edge pinned at the SAME
843
+ // line when collapsed as when expanded (the expand edge is correct).
844
+ // open: left = openRight, visible right edge = openRight+panelW
845
+ // closed: left = openRight + panelW, clip-right panelW hides it fully,
846
+ // so the visible right edge stays at openRight+panelW -> the
847
+ // collapse shrinks toward that line and vanishes exactly there.
848
+ var openRight = window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW;
849
+ panelLeft = open ? openRight : (openRight + panelW);
850
+ } else if (open) {
851
+ // LEFT dock open (left dock is the confirmed-correct reference — untouched)
852
+ panelLeft = (viewport ? viewport.left + 8 : 8);
691
853
  } else {
692
- // collapsed position:
693
- // left dock -> slide just past the left sidebar edge (stop there)
694
- // right dock -> boundary is the right sidebar when it is open,
695
- // otherwise the screen's right edge
696
- if (dockRight) {
697
- // with the right sidebar open, slide INTO the sidebar area (covered
698
- // by the cover wall); otherwise fly off the screen's right edge
699
- panelLeft = (viewport && viewport.right > 80)
700
- ? (window.innerWidth - viewport.right)
701
- : window.innerWidth + 24;
702
- } else {
703
- panelLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
704
- }
854
+ panelLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
705
855
  }
706
856
  var vMaxH = viewport ? Math.max(200, viewport.height - 28) : "72vh";
707
857
  var baseTopPx = (viewport ? viewport.top + 14 : window.innerHeight * 0.12) + panelY;
@@ -754,13 +904,38 @@ window.__ModuleLoader__.load({
754
904
  sp.scrollTo({ top: t, behavior: "smooth" });
755
905
  };
756
906
 
757
- // jump to the n-th match, cycling; also reveal the group in the outline
907
+ // jump to the n-th match, cycling; also reveal the group in the outline.
908
+ // ONE smooth scroll straight to the current occurrence (no competing
909
+ // scrolls): highlight first, then position the mark at the upper-middle
758
910
  var goToMatch = function (n) {
759
911
  if (matches.length === 0) return;
760
912
  var i = ((n % matches.length) + matches.length) % matches.length;
761
913
  setMatchIdx(i);
762
914
  var m = matches[i];
763
- jump(m.key, m.idx);
915
+ var q = query.trim().toLowerCase();
916
+ clearHighlights();
917
+ var r = findRowStrict(m.key);
918
+ if (r && q) {
919
+ // occurrence index within the target message (consecutive in matches)
920
+ var occ = 0;
921
+ for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
922
+ highlightRow(r, q, occ);
923
+ var markEl = r.querySelector(".dqt-current");
924
+ var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
925
+ if (sp && markEl) {
926
+ var target = markEl.getBoundingClientRect().top - sp.getBoundingClientRect().top + sp.scrollTop - sp.clientHeight * 0.35;
927
+ // stick-to-bottom nudge (same as jump): break the 25px floor hold
928
+ var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
929
+ if (floor - sp.scrollTop <= 25 && Math.abs(target - sp.scrollTop) > 60) {
930
+ sp.scrollTop = Math.max(0, floor - 26);
931
+ }
932
+ sp.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
933
+ } else {
934
+ jump(m.key, m.idx);
935
+ }
936
+ } else {
937
+ jump(m.key, m.idx);
938
+ }
764
939
  setVisibleCount(function (prev) {
765
940
  return Math.max(prev, Math.min(groups.length, groups.length - m.gi));
766
941
  });
@@ -828,9 +1003,12 @@ window.__ModuleLoader__.load({
828
1003
  // like being covered by the sidebar (the sidebar stays untouched):
829
1004
  // left dock -> clipped from the left up to the sidebar line
830
1005
  // right dock -> clipped from the right at the screen/sidebar line
1006
+ // LEFT dock keeps its original clip (collapse left). RIGHT dock clips the
1007
+ // LEFT side so the panel closes toward its right (dock) edge in place.
831
1008
  var panelClip = "inset(0 0 0 0px)";
832
1009
  if (!open) {
833
1010
  if (dockRight) {
1011
+ // mirror of LEFT: clip the RIGHT side (panel flies right, shrinking from its right)
834
1012
  panelClip = "inset(0 " + panelW + "px 0 0)";
835
1013
  } else {
836
1014
  panelClip = "inset(0 0 0 " + (panelW + 8) + "px)";
@@ -851,13 +1029,15 @@ window.__ModuleLoader__.load({
851
1029
  background: C.panelBg,
852
1030
  border: "none",
853
1031
  borderRadius: 12,
854
- boxShadow: "0 2px 10px rgba(0,0,0,0.18)",
855
1032
  zIndex: Z_BASE,
856
1033
  overflow: "hidden",
857
1034
  color: C.text,
858
1035
  opacity: panelOpacity,
859
1036
  clipPath: panelClip,
860
- boxShadow: "none",
1037
+ // inner shadow only (no outer): theme-aware (white bevel in dark mode,
1038
+ // soft shade in light) — outer shadow would be clipped by the collapse
1039
+ // clip-path and the panel edges.
1040
+ boxShadow: innerShadow(),
861
1041
  transition: panelTransition,
862
1042
  pointerEvents: "auto"
863
1043
  },
@@ -1043,26 +1223,69 @@ window.__ModuleLoader__.load({
1043
1223
  transition: "opacity 0.2s ease, transform 0.2s ease"
1044
1224
  },
1045
1225
  children: [
1046
- react_jsx_runtime.jsx("input", {
1047
- ref: searchRef,
1048
- value: query,
1049
- onChange: function (e) { onQueryChange(e.target.value); },
1050
- onKeyDown: function (e) {
1051
- if (e.key === "Enter") { goToMatch(matchIdx + 1); e.preventDefault(); }
1052
- if (e.key === "Escape") { closeSearch(); }
1053
- },
1054
- placeholder: "搜索标题,回车定位…",
1226
+ // input with a custom animated placeholder: the box stays static,
1227
+ // only the placeholder text fades when the scope switches
1228
+ react_jsx_runtime.jsx("div", {
1055
1229
  style: {
1230
+ position: "relative",
1056
1231
  flex: 1,
1057
1232
  minWidth: 0,
1233
+ display: "flex",
1234
+ alignItems: "center",
1058
1235
  background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1059
- border: "none",
1060
- borderRadius: 6,
1061
- padding: "3px 8px",
1062
- fontSize: 12,
1063
- color: C.text,
1064
- outline: "none"
1065
- }
1236
+ borderRadius: 999,
1237
+ padding: "6px 12px"
1238
+ },
1239
+ children: [
1240
+ react_jsx_runtime.jsx("input", {
1241
+ ref: searchRef,
1242
+ value: query,
1243
+ onChange: function (e) { onQueryChange(e.target.value); },
1244
+ onKeyDown: function (e) {
1245
+ if (e.key === "Enter") { goToMatch(matchIdx + 1); e.preventDefault(); }
1246
+ if (e.key === "Escape") { closeSearch(); }
1247
+ },
1248
+ style: {
1249
+ flex: 1,
1250
+ minWidth: 0,
1251
+ width: "100%",
1252
+ background: "transparent",
1253
+ border: "none",
1254
+ outline: "none",
1255
+ fontSize: 13,
1256
+ color: C.text,
1257
+ padding: 0
1258
+ }
1259
+ }),
1260
+ (!query) ? react_jsx_runtime.jsx("span", {
1261
+ style: {
1262
+ position: "absolute",
1263
+ left: 12,
1264
+ pointerEvents: "none",
1265
+ fontSize: 13,
1266
+ color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
1267
+ },
1268
+ children: [
1269
+ "搜索",
1270
+ react_jsx_runtime.jsx("span", {
1271
+ style: { position: "relative", display: "inline-block" },
1272
+ children: [
1273
+ react_jsx_runtime.jsx("span", {
1274
+ key: searchScope,
1275
+ style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
1276
+ children: searchScope === "title" ? "标题" : "全文"
1277
+ }),
1278
+ (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1279
+ key: "old-" + prevScope,
1280
+ style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
1281
+ children: prevScope === "title" ? "标题" : "全文"
1282
+ }) : null
1283
+ ]
1284
+ }),
1285
+ ",回车定位…"
1286
+ ]
1287
+ }) : null,
1288
+ ]
1066
1289
  }),
1067
1290
  searching ? react_jsx_runtime.jsx("span", {
1068
1291
  style: { width: 13, height: 13, flex: "none", display: "flex", alignItems: "center", justifyContent: "center" },
@@ -1083,6 +1306,43 @@ window.__ModuleLoader__.load({
1083
1306
  }) : react_jsx_runtime.jsx("span", {
1084
1307
  style: { fontSize: 11, color: C.muted, flex: "none", minWidth: 30, textAlign: "center" },
1085
1308
  children: matches.length > 0 ? ((matchIdx % matches.length) + 1) + "/" + matches.length : ""
1309
+ }),
1310
+ // scope toggle: 标题 <-> 全文 (round pill; background fades,
1311
+ // label text cross-fades old->new)
1312
+ react_jsx_runtime.jsx("button", {
1313
+ onClick: toggleScope,
1314
+ title: searchScope === "title" ? "当前:仅搜索标题。点击切换为全文搜索" : "当前:全文搜索。点击切换为仅标题",
1315
+ style: {
1316
+ flex: "none",
1317
+ width: 34,
1318
+ height: 34,
1319
+ display: "flex",
1320
+ alignItems: "center",
1321
+ justifyContent: "center",
1322
+ borderRadius: "50%",
1323
+ fontSize: 11,
1324
+ color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1325
+ background: searchScope === "full" ? "rgba(79,140,255,0.18)" : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1326
+ border: "none",
1327
+ cursor: "pointer",
1328
+ whiteSpace: "nowrap",
1329
+ transition: "background 0.25s ease"
1330
+ },
1331
+ children: react_jsx_runtime.jsx("span", {
1332
+ style: { position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" },
1333
+ children: [
1334
+ react_jsx_runtime.jsx("span", {
1335
+ key: searchScope,
1336
+ style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
1337
+ children: searchScope === "title" ? "标题" : "全文"
1338
+ }),
1339
+ (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1340
+ key: "old-" + prevScope,
1341
+ style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
1342
+ children: prevScope === "title" ? "标题" : "全文"
1343
+ }) : null
1344
+ ]
1345
+ })
1086
1346
  })
1087
1347
  ]
1088
1348
  })
@@ -1092,6 +1352,7 @@ window.__ModuleLoader__.load({
1092
1352
  // the scrollbar follows the dock side (rtl flips it to the left)
1093
1353
  react_jsx_runtime.jsx("div", {
1094
1354
  ref: listRef,
1355
+ className: "dqt-list",
1095
1356
  style: {
1096
1357
  overflowY: "auto",
1097
1358
  padding: "6px 8px",
@@ -1205,9 +1466,10 @@ window.__ModuleLoader__.load({
1205
1466
  })
1206
1467
  }) : null;
1207
1468
 
1208
- return react_jsx_runtime.jsx(ErrorBoundary, {
1209
- children: [panelEl, edgeEl]
1210
- });
1469
+ return react_dom.createPortal(
1470
+ react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl] }),
1471
+ document.body
1472
+ );
1211
1473
  }
1212
1474
 
1213
1475
  function renderItem(n, depth, jump, C, uid) {
@@ -1326,15 +1588,6 @@ window.__ModuleLoader__.load({
1326
1588
  return out;
1327
1589
  }
1328
1590
 
1329
- function OutlineOverlay(props) {
1330
- var SessionProvider = props.SessionProvider;
1331
- var renderSlot = props.renderSlot;
1332
- return react_jsx_runtime.jsx(SessionProvider, {
1333
- empty: function () { return null; },
1334
- children: function () { return renderSlot("quick-toc.panel", {}); }
1335
- });
1336
- }
1337
-
1338
1591
  var zh = {
1339
1592
  "panel.title": "对话大纲"
1340
1593
  };
@@ -1342,38 +1595,48 @@ window.__ModuleLoader__.load({
1342
1595
  "panel.title": "Conversation Outline"
1343
1596
  };
1344
1597
 
1345
- var inject = ["slots", "sessions", "locale"];
1598
+ var inject = ["slots", "locale"];
1346
1599
 
1347
1600
  function apply(ctx) {
1348
1601
  ctx.effect(function () {
1349
1602
  return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
1350
1603
  }, "dsh-quick-toc: dictionaries");
1351
- ctx.slots.inject("shell.overlay", function () {
1604
+ // DSH 0.1.2-rc.1: session-scoped hooks (useChat/useSession/sessionId) only arrive
1605
+ // inside a declared session slot. Register the panel into the session-scoped
1606
+ // conversation.input.overlay (list/additive) so it receives useChat; the panel
1607
+ // itself renders a fixed, frame-floating dock, so the overlay seat is just the
1608
+ // hook source (it is NOT the panel's visual container).
1609
+ ctx.slots.inject("conversation.input.overlay", function () {
1352
1610
  return ctx.slots.register({
1353
- name: "shell.overlay",
1611
+ name: "conversation.input.overlay",
1354
1612
  id: "quick-toc",
1355
1613
  order: 90,
1356
- children: { "quick-toc.panel": { kind: "single", scope: "session" } }
1357
- }, OutlineOverlay);
1358
- });
1359
- ctx.slots.inject("quick-toc.panel", function () {
1360
- return ctx.slots.register({
1361
- name: "quick-toc.panel",
1362
1614
  locale: "dsh-quick-toc"
1363
1615
  }, OutlinePanel);
1364
1616
  });
1365
1617
  }
1366
1618
 
1619
+ exports.name = "dsh-quick-toc"; // runner attributes slot entries to this registrant
1367
1620
  exports.apply = apply;
1368
1621
  exports.inject = inject;
1369
1622
 
1370
- // inject the small keyframe stylesheet once (spinner rotation)
1623
+ // inject the small keyframe + panel-scrollbar stylesheet once
1371
1624
  (function () {
1372
1625
  if (typeof document === "undefined") return;
1373
1626
  if (document.getElementById("dsh-quick-toc-css")) return;
1374
1627
  var s = document.createElement("style");
1375
1628
  s.id = "dsh-quick-toc-css";
1376
- s.textContent = "@keyframes dqt-spin{to{transform:rotate(360deg)}}";
1629
+ s.textContent = "@keyframes dqt-spin{to{transform:rotate(360deg)}}@keyframes dqt-fade-in{from{opacity:0}to{opacity:1}}@keyframes dqt-fade-out{from{opacity:1}to{opacity:0}}" +
1630
+ // Hide the native scrollbar entirely (no arrow buttons, no grey bar) —
1631
+ // custom webkit scrollbar styling was not reliably suppressing the
1632
+ // default arrows, so a hidden native bar + the panel's inner shadow gives
1633
+ // the cleanest look. Scrolling still works (auto-follow + wheel-load-older).
1634
+ ".dqt-list{scrollbar-width:none;-ms-overflow-style:none}" +
1635
+ ".dqt-list::-webkit-scrollbar{display:none;width:0;height:0}" +
1636
+ ".dqt-list::-webkit-scrollbar-button{display:none;height:0;width:0}" +
1637
+ ".dqt-list::-webkit-scrollbar-track{background:transparent}" +
1638
+ ".dqt-list::-webkit-scrollbar-thumb{background:transparent}" +
1639
+ ".dqt-list::-webkit-scrollbar-corner{background:transparent}";
1377
1640
  document.head.appendChild(s);
1378
1641
  })();
1379
1642
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.2.0-beta",
3
+ "version": "0.3.0",
4
4
  "description": "Quick conversation TOC for DeepSeek Harness: markdown heading outline grouped by turn, with auto-follow highlight and smooth jump navigation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,12 @@
8
8
  "lib",
9
9
  "cordis.patch.yml",
10
10
  "README.md",
11
+ "README.en.md",
11
12
  "LICENSE"
12
13
  ],
14
+ "engines": {
15
+ "dsh": ">=0.1.2-rc.1"
16
+ },
13
17
  "exports": {
14
18
  ".": "./lib/index.js",
15
19
  "./client": "./lib/client.js",
@@ -21,13 +25,23 @@
21
25
  },
22
26
  "client": {
23
27
  "inject": [
24
- "@deepseek-ai/dsh-client-runtime",
28
+ "@deepseek-ai/dsh-client-ui-chat",
25
29
  "@deepseek-ai/dsh-client-ui-conversation",
26
30
  "@deepseek-ai/dsh-client-ui-layout"
27
31
  ],
28
32
  "platform": "web"
33
+ },
34
+ "compatibility": {
35
+ "dshReleases": {
36
+ "0.1.2-rc.1": "compatible"
37
+ }
29
38
  }
30
39
  },
40
+ "peerDependencies": {
41
+ "react": "^18.2.0",
42
+ "@deepseek-ai/dsh-client-ui-chat": ">=0.1.2-rc.1",
43
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.2-rc.1"
44
+ },
31
45
  "license": "MIT",
32
46
  "keywords": [
33
47
  "dsh",