dsh-quick-toc 0.3.2 → 0.4.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/lib/client.js CHANGED
@@ -7,7 +7,7 @@ window.__ModuleLoader__.load({
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
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.
10
+ // Recent DSH (0.1.5-rc.1): the old @deepseek-ai/dsh-client-runtime package is gone.
11
11
  // Runtime hooks (useChat etc.) now arrive as session-scope slot props —
12
12
  // no runtime package to require here anymore.
13
13
 
@@ -78,12 +78,28 @@ window.__ModuleLoader__.load({
78
78
  return first;
79
79
  }
80
80
 
81
+ // Headings are collected line by line so FENCED CODE BLOCKS can be skipped:
82
+ // a "```" block that documents markdown (or shows a shell comment like
83
+ // "# install") contains lines that look like headings but are code — they
84
+ // must not become outline entries (they have no element to jump to, and they
85
+ // shifted the index of the real headings).
81
86
  function parseHeadings(text) {
82
87
  var items = [];
83
- var re = /^(#{1,6})\s+(.+?)\s*#*\s*$/gm;
84
- var m;
85
- while ((m = re.exec(text)) !== null) {
86
- items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
88
+ if (!text) return items;
89
+ var lines = text.split("\n");
90
+ var fence = null; // { char, len } while inside a fenced code block
91
+ for (var i = 0; i < lines.length; i++) {
92
+ var line = lines[i];
93
+ var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
94
+ if (fence !== null) {
95
+ // a closing fence uses the same character, is at least as long and has
96
+ // no info string after it
97
+ if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
98
+ continue;
99
+ }
100
+ if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
101
+ var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
102
+ if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
87
103
  }
88
104
  return items;
89
105
  }
@@ -101,6 +117,74 @@ window.__ModuleLoader__.load({
101
117
  return root.children;
102
118
  }
103
119
 
120
+ // ---- per-node extraction cache ----
121
+ // The conversation store keeps the same node objects for unchanged messages,
122
+ // so a streaming update only re-joins/re-parses the node that actually
123
+ // changed instead of every message in the history.
124
+ var EMPTY_HEADINGS = [];
125
+ var nodeInfoCache = new Map();
126
+ function nodeInfo(key, node) {
127
+ var isUser = node.kind === "user";
128
+ var blocks = node.data ? node.data.blocks : undefined;
129
+ var cached = nodeInfoCache.get(key);
130
+ if (cached !== undefined &&
131
+ (cached.node === node || (blocks !== undefined && cached.blocks === blocks && cached.user === isUser))) {
132
+ return cached;
133
+ }
134
+ var text = isUser ? extractUserText(node) : extractReplyText(node);
135
+ var info = {
136
+ node: node,
137
+ blocks: blocks,
138
+ user: isUser,
139
+ text: text,
140
+ headings: node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS
141
+ };
142
+ nodeInfoCache.set(key, info);
143
+ return info;
144
+ }
145
+ // Drop cache records whose node left the conversation (bounded growth).
146
+ function pruneNodeInfo(seen) {
147
+ if (nodeInfoCache.size <= 200) return;
148
+ nodeInfoCache.forEach(function (value, key) {
149
+ if (!seen[key]) nodeInfoCache.delete(key);
150
+ });
151
+ }
152
+
153
+ // stable id for one heading occurrence (a message can hold several)
154
+ function headingId(h) {
155
+ return h.key + "#" + h.idx;
156
+ }
157
+
158
+ // Split `text` into runs around case-insensitive occurrences of `q`, so the
159
+ // caller can render the hits distinctly. Returns [{ text, hit }, ...].
160
+ function highlightParts(text, q) {
161
+ var parts = [];
162
+ if (!text) return parts;
163
+ if (!q) return [{ text: text, hit: false }];
164
+ var lower = text.toLowerCase();
165
+ var from = 0;
166
+ for (;;) {
167
+ var at = lower.indexOf(q, from);
168
+ if (at === -1) break;
169
+ if (at > from) parts.push({ text: text.slice(from, at), hit: false });
170
+ parts.push({ text: text.slice(at, at + q.length), hit: true });
171
+ from = at + q.length;
172
+ }
173
+ if (from < text.length) parts.push({ text: text.slice(from), hit: false });
174
+ return parts;
175
+ }
176
+
177
+ // A one-line window around the first hit of `q` in `text` (for search results).
178
+ function snippetAround(text, q, span) {
179
+ if (!text) return "";
180
+ var flat = text.replace(/\s+/g, " ").trim();
181
+ var at = flat.toLowerCase().indexOf(q);
182
+ if (at === -1) return previewText(flat, span * 2);
183
+ var start = Math.max(0, at - span);
184
+ var end = Math.min(flat.length, at + q.length + span);
185
+ return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
186
+ }
187
+
104
188
  // strict lookup: exact dataset match or CSS-escaped selector only —
105
189
  // no fuzzy contains matching (avoids landing on the wrong row);
106
190
  // hidden rows (zero rect, e.g. duplicate/hidden copies) are skipped
@@ -145,6 +229,10 @@ window.__ModuleLoader__.load({
145
229
 
146
230
  // count every occurrence of q (case-insensitive) inside text
147
231
  function countOccurrences(text, q) {
232
+ // an empty needle would make indexOf return 0 forever and freeze the tab;
233
+ // every caller passes a trimmed non-empty query today, but the guard keeps
234
+ // a future call site from hanging the UI.
235
+ if (!text || !q) return 0;
148
236
  var lower = text.toLowerCase();
149
237
  var count = 0;
150
238
  var idx = 0;
@@ -168,6 +256,56 @@ window.__ModuleLoader__.load({
168
256
  return null;
169
257
  }
170
258
 
259
+ // ---- which view is the conversation area showing? ----
260
+ // The center column hosts ONE view at a time (chat / trajectory / context /
261
+ // any plugin view): DSH renders only the selected view entry, but the
262
+ // scrollport is shared, so our overlay would otherwise float over every view.
263
+ // The tab list (rendered when there is more than one view) marks the active
264
+ // view with aria-selected; ui-chat registers the chat view with order 0, so
265
+ // its tab comes first. Anything unknowable degrades to "chat" (visible).
266
+ var CHAT_VIEW_LABELS = ["对话", "chat"];
267
+ // Cached so the fast poll costs one isConnected check in the common case.
268
+ var _chatTablistCache = null;
269
+ function conversationTablist() {
270
+ var cached = _chatTablistCache;
271
+ if (cached && cached.isConnected) return cached;
272
+ var found = null;
273
+ // Scope the lookup to the conversation area: walk up from the shared
274
+ // scrollport and take the first tab list an ancestor owns, so an
275
+ // unrelated tab list (e.g. inside a settings dialog) can never match.
276
+ var sp = document.querySelector("[data-conversation-scroll]");
277
+ if (sp) {
278
+ var node = sp;
279
+ for (var d = 0; d < 6 && node && !found; d++) {
280
+ node = node.parentElement;
281
+ if (node) found = node.querySelector('[role="tablist"]');
282
+ }
283
+ }
284
+ if (!found) found = document.querySelector('[role="tablist"]');
285
+ _chatTablistCache = found;
286
+ return found;
287
+ }
288
+ function isChatViewActive() {
289
+ try {
290
+ var tablist = conversationTablist();
291
+ if (!tablist) return true; // a single view: no tab list, and it is chat
292
+ var tabs = tablist.querySelectorAll('button[role="tab"]');
293
+ if (tabs.length < 2) return true;
294
+ var activeIdx = -1;
295
+ var chatIdx = -1;
296
+ for (var i = 0; i < tabs.length; i++) {
297
+ var label = (tabs[i].textContent || "").trim().toLowerCase();
298
+ if (chatIdx === -1 && CHAT_VIEW_LABELS.indexOf(label) !== -1) chatIdx = i;
299
+ if (activeIdx === -1 && tabs[i].getAttribute("aria-selected") === "true") activeIdx = i;
300
+ }
301
+ if (activeIdx === -1) return true; // no explicit selection -> assume chat
302
+ if (chatIdx === -1) chatIdx = 0; // chat is order 0 -> the first tab
303
+ return activeIdx === chatIdx;
304
+ } catch (e) {
305
+ return true;
306
+ }
307
+ }
308
+
171
309
  // ---- search keyword highlight in the conversation ----
172
310
  var highlightSpans = [];
173
311
 
@@ -195,26 +333,36 @@ window.__ModuleLoader__.load({
195
333
  var text = node.nodeValue;
196
334
  if (!text) continue;
197
335
  var lower = text.toLowerCase();
198
- var idx = lower.indexOf(q);
199
- if (idx < 0) continue;
336
+ if (lower.indexOf(q) < 0) continue;
200
337
  var frag = document.createDocumentFragment();
201
- var before = text.slice(0, idx);
202
- if (before) frag.appendChild(document.createTextNode(before));
203
- var mark = document.createElement("span");
204
- if (occ === currentOcc) {
205
- mark.className = "dqt-current";
206
- mark.style.background = "rgba(79,140,255,0.55)";
207
- mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
208
- } else {
209
- mark.style.background = "rgba(79,140,255,0.32)";
338
+ // EVERY occurrence inside this text node must be wrapped, in order: the
339
+ // caller addresses hits by their global index (`occ`), which is counted
340
+ // over the message text. Wrapping only the first one per text node made
341
+ // a second hit on the same line un-markable, so stepping to it found no
342
+ // `.dqt-current` and fell back to a plain scroll with no highlight.
343
+ var from = 0;
344
+ var idx = lower.indexOf(q, from);
345
+ while (idx !== -1) {
346
+ var before = text.slice(from, idx);
347
+ if (before) frag.appendChild(document.createTextNode(before));
348
+ var mark = document.createElement("span");
349
+ if (occ === currentOcc) {
350
+ mark.className = "dqt-current";
351
+ mark.style.background = "rgba(79,140,255,0.55)";
352
+ mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
353
+ } else {
354
+ mark.style.background = "rgba(79,140,255,0.32)";
355
+ }
356
+ mark.style.borderRadius = "2px";
357
+ mark.style.color = "inherit";
358
+ mark.textContent = text.slice(idx, idx + q.length);
359
+ frag.appendChild(mark);
360
+ highlightSpans.push(mark);
361
+ occ++;
362
+ from = idx + q.length;
363
+ idx = lower.indexOf(q, from);
210
364
  }
211
- mark.style.borderRadius = "2px";
212
- mark.style.color = "inherit";
213
- mark.textContent = text.slice(idx, idx + q.length);
214
- frag.appendChild(mark);
215
- highlightSpans.push(mark);
216
- occ++;
217
- var after = text.slice(idx + q.length);
365
+ var after = text.slice(from);
218
366
  if (after) frag.appendChild(document.createTextNode(after));
219
367
  node.parentNode.replaceChild(frag, node);
220
368
  }
@@ -306,13 +454,13 @@ window.__ModuleLoader__.load({
306
454
 
307
455
  // ---------- component ----------
308
456
  function OutlinePanel(props) {
309
- // DSH 0.1.2-rc.1: the conversation moved out of the session snapshot —
457
+ // Recent DSH (0.1.5-rc.1): the conversation moved out of the session snapshot —
310
458
  // it is now the session-scope `chat` hook (ChatSnapshot: order + nodes
311
459
  // map + legacy projection), contributed by dsh-client-ui-chat. Same node
312
460
  // shape as before (kind user/assistant-step, location.turn, data.blocks).
313
461
  var useChat = props.useChat;
314
462
  if (!useChat) {
315
- console.warn("[dsh-quick-toc] useChat prop missing (requires DSH >= 0.1.2-rc.1)");
463
+ console.warn("[dsh-quick-toc] useChat prop missing (requires DSH >= 0.1.5-rc.1)");
316
464
  return null;
317
465
  }
318
466
  var order = useChat(function (s) { return s.order; });
@@ -425,6 +573,72 @@ window.__ModuleLoader__.load({
425
573
  var panelH = _s8[0];
426
574
  var setPanelH = _s8[1];
427
575
 
576
+ // ---- heading level filter (persisted) ----
577
+ // An arbitrary SET of levels, not a "show up to N" prefix: every chip is an
578
+ // independent on/off switch, so H1 + H3 without H2 is a valid view. Turning
579
+ // the last remaining level off restores all six (the panel is never empty).
580
+ var LEVELS_KEY = "dsh-quick-toc.levels.v1";
581
+ var OLD_MAX_LEVEL_KEY = "dsh-quick-toc.maxLevel.v1";
582
+ var ALL_LEVELS = [1, 2, 3, 4, 5, 6];
583
+ var _sLv = react.useState(function () {
584
+ try {
585
+ var raw = localStorage.getItem(LEVELS_KEY);
586
+ if (raw !== null) {
587
+ var arr = JSON.parse(raw);
588
+ if (Array.isArray(arr)) {
589
+ var kept = ALL_LEVELS.filter(function (lv) { return arr.indexOf(lv) !== -1; });
590
+ if (kept.length > 0) return kept;
591
+ }
592
+ }
593
+ // migrate the "show up to N" value written by earlier builds
594
+ var v = Number(localStorage.getItem(OLD_MAX_LEVEL_KEY));
595
+ if (isFinite(v) && v >= 1 && v <= 6) {
596
+ return ALL_LEVELS.filter(function (lv) { return lv <= v; });
597
+ }
598
+ } catch (e) {}
599
+ return ALL_LEVELS.slice();
600
+ });
601
+ var levels = _sLv[0];
602
+ var setLevels = _sLv[1];
603
+ var levelSet = react.useMemo(function () {
604
+ var m = {};
605
+ for (var i = 0; i < levels.length; i++) m[levels[i]] = true;
606
+ return m;
607
+ }, [levels]);
608
+ var toggleLevel = function (lv) {
609
+ var next;
610
+ if (levelSet[lv]) {
611
+ next = levels.filter(function (x) { return x !== lv; });
612
+ if (next.length === 0) next = ALL_LEVELS.slice(); // never leave the panel empty
613
+ } else {
614
+ next = ALL_LEVELS.filter(function (x) { return x === lv || levelSet[x]; });
615
+ }
616
+ setLevels(next);
617
+ try { localStorage.setItem(LEVELS_KEY, JSON.stringify(next)); } catch (e) {}
618
+ };
619
+
620
+ // ---- level-filter row (the H1–H6 chips) can be collapsed away; the toggle
621
+ // sits next to the search button in the panel header and the choice is
622
+ // remembered. The filter itself keeps applying while the row is hidden.
623
+ var LEVELS_ROW_KEY = "dsh-quick-toc.levelsRow.v1";
624
+ var _sLr = react.useState(function () {
625
+ try { return localStorage.getItem(LEVELS_ROW_KEY) !== "0"; } catch (e) { return true; }
626
+ });
627
+ var showLevelsRow = _sLr[0];
628
+ var setShowLevelsRow = _sLr[1];
629
+ var toggleLevelsRow = function () {
630
+ var next = !showLevelsRow;
631
+ setShowLevelsRow(next);
632
+ try { localStorage.setItem(LEVELS_ROW_KEY, next ? "1" : "0"); } catch (e) {}
633
+ };
634
+
635
+ // ---- breadcrumb: the heading the reader is currently inside ----
636
+ var _sCrumb = react.useState(null);
637
+ var crumb = _sCrumb[0];
638
+ var setCrumb = _sCrumb[1];
639
+ var crumbRef = react.useRef(null);
640
+ var headingPathsRef = react.useRef({});
641
+
428
642
  // ---- pagination: show the latest N groups; scrolling to the top loads older ----
429
643
  var PAGE_SIZE = 6;
430
644
  var _s9 = react.useState(PAGE_SIZE);
@@ -434,6 +648,39 @@ window.__ModuleLoader__.load({
434
648
  var didInitScroll = react.useRef(false);
435
649
  var outlineTouchRef = react.useRef(0); // last time the user touched the outline
436
650
 
651
+ // ---- the outline belongs to the CHAT view only: when the center column
652
+ // switches to another view (trajectory / context / plugin views), fade the
653
+ // panel and its collapsed handle out. Polled lightly because view switches
654
+ // are user clicks and the tab DOM is remounted on session/view changes.
655
+ var _sView = react.useState(true);
656
+ var chatViewActive = _sView[0];
657
+ var setChatViewActive = _sView[1];
658
+ var chatViewRef = react.useRef(true);
659
+ // True briefly after a view switch: lets the fade use its own fast
660
+ // transition instead of the slower dock/collapse one.
661
+ var _sFade = react.useState(false);
662
+ var viewFading = _sFade[0];
663
+ var setViewFading = _sFade[1];
664
+ var viewFadeTimerRef = react.useRef(null);
665
+ react.useEffect(function () {
666
+ var detect = function () {
667
+ var v = isChatViewActive();
668
+ if (v !== chatViewRef.current) {
669
+ chatViewRef.current = v;
670
+ setChatViewActive(v);
671
+ setViewFading(true);
672
+ if (viewFadeTimerRef.current) clearTimeout(viewFadeTimerRef.current);
673
+ viewFadeTimerRef.current = setTimeout(function () { setViewFading(false); }, 380);
674
+ }
675
+ };
676
+ detect();
677
+ var timer = setInterval(detect, 120);
678
+ return function () {
679
+ clearInterval(timer);
680
+ if (viewFadeTimerRef.current) clearTimeout(viewFadeTimerRef.current);
681
+ };
682
+ }, []);
683
+
437
684
  // ---- search: header button opens a keyword box; Enter cycles through
438
685
  // matching headings and jumps to each ----
439
686
  var _s13 = react.useState(false);
@@ -510,13 +757,6 @@ window.__ModuleLoader__.load({
510
757
  var setActiveGroup = _s10[1];
511
758
  var activeSigRef = react.useRef("");
512
759
 
513
- // first time content appears, scroll the list to the bottom (newest)
514
- react.useEffect(function () {
515
- if (didInitScroll.current || !listRef.current || !groups || groups.length === 0) return;
516
- didInitScroll.current = true;
517
- listRef.current.scrollTop = listRef.current.scrollHeight;
518
- }, [groups ? groups.length : 0]);
519
-
520
760
  // load older outline groups; when everything is loaded, click the
521
761
  // conversation's own "load older" button so older turns keep appearing
522
762
  var loadOlderOutline = function (el) {
@@ -677,6 +917,7 @@ window.__ModuleLoader__.load({
677
917
  var turnUserText = {};
678
918
  var turnUserFull = {};
679
919
  if (!order || !nodes) return result;
920
+ var seen = {};
680
921
  // first pass: per-turn time — the LAST message of the turn wins (end time);
681
922
  // also remember each turn's user message key + first-line preview + full text
682
923
  for (var i = 0; i < order.length; i++) {
@@ -686,10 +927,12 @@ window.__ModuleLoader__.load({
686
927
  var l0 = n0.location;
687
928
  var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
688
929
  if (tid0 === null) continue;
930
+ seen[k0] = true;
931
+ var info0 = nodeInfo(k0, n0);
689
932
  if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
690
933
  turnUserKey[tid0] = k0;
691
- turnUserText[tid0] = previewText(extractUserText(n0), 30);
692
- turnUserFull[tid0] = extractUserText(n0);
934
+ turnUserText[tid0] = previewText(info0.text, 30);
935
+ turnUserFull[tid0] = info0.text;
693
936
  }
694
937
  var t0 = getNodeTime(n0);
695
938
  if (t0) turnTimes[tid0] = t0;
@@ -714,22 +957,37 @@ window.__ModuleLoader__.load({
714
957
  };
715
958
  result.push(current);
716
959
  }
717
- var text = extractReplyText(node);
718
- current.msgs.push({ key: key, text: text });
719
- var parsed = parseHeadings(text);
720
- var hIdx = 0;
721
- for (var k = 0; k < parsed.length; k++) {
722
- current.headings.push({ level: parsed[k].level, title: parsed[k].title, key: key, idx: hIdx });
723
- hIdx++;
960
+ seen[key] = true;
961
+ var info = nodeInfo(key, node);
962
+ current.msgs.push({ key: key, text: info.text });
963
+ for (var k = 0; k < info.headings.length; k++) {
964
+ current.headings.push({ level: info.headings[k].level, title: info.headings[k].title, key: key, idx: k });
724
965
  }
725
966
  }
967
+ pruneNodeInfo(seen);
726
968
  // keep a turn when it has headings OR a time — turns without headings
727
969
  // still get a standalone time entry in the outline (click to jump)
728
970
  return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
729
971
  }, [order, nodes]);
972
+ // first time content appears, scroll the list to the bottom (newest).
973
+ // NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
974
+ // during render, so reading `groups` before that `var` is assigned would
975
+ // freeze the deps at 0 and the effect would only ever run on mount.
976
+ react.useEffect(function () {
977
+ if (didInitScroll.current || !listRef.current || groups.length === 0) return;
978
+ didInitScroll.current = true;
979
+ listRef.current.scrollTop = listRef.current.scrollHeight;
980
+ }, [groups.length]);
730
981
  var groupTrees = react.useMemo(function () {
731
- return groups.map(function (g) { return buildTree(g.headings); });
732
- }, [groups]);
982
+ return groups.map(function (g) {
983
+ if (levels.length === 6) return buildTree(g.headings);
984
+ var kept = [];
985
+ for (var i = 0; i < g.headings.length; i++) {
986
+ if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
987
+ }
988
+ return buildTree(kept);
989
+ });
990
+ }, [groups, levels, levelSet]);
733
991
  // pagination slice: the latest `visibleCount` groups
734
992
  var shownGroups = groups.slice(Math.max(0, groups.length - visibleCount));
735
993
  var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
@@ -745,6 +1003,80 @@ window.__ModuleLoader__.load({
745
1003
  }
746
1004
  return m;
747
1005
  }, [groups]);
1006
+
1007
+ // ancestor path of every heading occurrence (breadcrumb + result rows):
1008
+ // id -> { path, title, level, gi }
1009
+ var headingPaths = react.useMemo(function () {
1010
+ var byId = {};
1011
+ var lastTitle = {};
1012
+ for (var gi = 0; gi < groups.length; gi++) {
1013
+ var g = groups[gi];
1014
+ for (var j = 0; j < g.headings.length; j++) {
1015
+ var h = g.headings[j];
1016
+ for (var deeper = h.level + 1; deeper <= 6; deeper++) lastTitle[deeper] = undefined;
1017
+ var parts = [];
1018
+ for (var up = 1; up < h.level; up++) {
1019
+ if (lastTitle[up]) parts.push(lastTitle[up]);
1020
+ }
1021
+ parts.push(h.title);
1022
+ byId[headingId(h)] = { path: parts.join(" › "), title: h.title, level: h.level, gi: gi };
1023
+ lastTitle[h.level] = h.title;
1024
+ }
1025
+ }
1026
+ return byId;
1027
+ }, [groups]);
1028
+
1029
+ // search result rows: one row per matched heading/message (with an
1030
+ // occurrence count), carrying its heading path, turn time and a snippet.
1031
+ var resultRows = react.useMemo(function () {
1032
+ var q = query.trim().toLowerCase();
1033
+ if (!q) return [];
1034
+ var rows = [];
1035
+ var index = {};
1036
+ var push = function (row, n) {
1037
+ var id = row.key + "#" + (row.idx === undefined ? "-" : row.idx);
1038
+ var known = index[id];
1039
+ if (known !== undefined) { known.count += n; return; }
1040
+ row.count = n;
1041
+ index[id] = row;
1042
+ rows.push(row);
1043
+ };
1044
+ for (var gi = 0; gi < groups.length; gi++) {
1045
+ var g = groups[gi];
1046
+ for (var j = 0; j < g.headings.length; j++) {
1047
+ var h = g.headings[j];
1048
+ var nh = countOccurrences(h.title, q);
1049
+ if (nh > 0) {
1050
+ var hp = headingPaths[headingId(h)];
1051
+ push({
1052
+ gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
1053
+ path: hp ? hp.path : "", time: g.time, snippet: ""
1054
+ }, nh);
1055
+ }
1056
+ }
1057
+ if (searchScope === "full") {
1058
+ var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, q) : 0;
1059
+ if (nu > 0) {
1060
+ push({
1061
+ gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
1062
+ level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, q, 36)
1063
+ }, nu);
1064
+ }
1065
+ for (var m = 0; m < g.msgs.length; m++) {
1066
+ var msg = g.msgs[m];
1067
+ if (!msg.text) continue;
1068
+ var nm = countOccurrences(msg.text, q);
1069
+ if (nm === 0) continue;
1070
+ push({
1071
+ gi: gi, key: msg.key, idx: undefined, title: previewText(msg.text, 40),
1072
+ level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, q, 36)
1073
+ }, nm);
1074
+ }
1075
+ }
1076
+ }
1077
+ return rows;
1078
+ }, [groups, query, searchScope, headingPaths]);
1079
+ headingPathsRef.current = headingPaths;
748
1080
  react.useEffect(function () {
749
1081
  var sp = document.querySelector("[data-conversation-scroll]");
750
1082
  if (!sp) return;
@@ -791,6 +1123,43 @@ window.__ModuleLoader__.load({
791
1123
  }
792
1124
  }
793
1125
  }
1126
+ // breadcrumb: the heading of the section that occupies most of the
1127
+ // viewport — i.e. the deepest heading above the viewport's MIDDLE line.
1128
+ // Walking backwards lets a heading we cannot map (e.g. one inside a tool
1129
+ // result) fall back to the nearest mapped ancestor heading.
1130
+ //
1131
+ // The middle line is what makes this consistent with every jump: a jump
1132
+ // (outline click, breadcrumb click, match stepping) always lands its
1133
+ // target ABOVE the middle, so clicking the breadcrumb never changes
1134
+ // which heading the breadcrumb shows.
1135
+ var crumbLine = vTop + lr.height * 0.5;
1136
+ var heads = sp.querySelectorAll("h1,h2,h3,h4,h5,h6");
1137
+ var nextCrumb = null;
1138
+ var paths = headingPathsRef.current;
1139
+ for (var hi = heads.length - 1; hi >= 0; hi--) {
1140
+ var hr = heads[hi].getBoundingClientRect();
1141
+ if (hr.top > crumbLine) continue;
1142
+ var rowEl = heads[hi].closest ? heads[hi].closest("[data-chat-anchor-key]") : null;
1143
+ if (!rowEl || !rowEl.dataset) continue;
1144
+ var rowKey = rowEl.dataset.chatAnchorKey;
1145
+ var rowHeads = rowEl.querySelectorAll("h1,h2,h3,h4,h5,h6");
1146
+ var idxInRow = -1;
1147
+ for (var rh = 0; rh < rowHeads.length; rh++) {
1148
+ if (rowHeads[rh] === heads[hi]) { idxInRow = rh; break; }
1149
+ }
1150
+ var meta = idxInRow >= 0 ? paths[rowKey + "#" + idxInRow] : undefined;
1151
+ if (meta !== undefined) {
1152
+ nextCrumb = { path: meta.path, key: rowKey, idx: idxInRow };
1153
+ break;
1154
+ }
1155
+ }
1156
+ var prev = crumbRef.current;
1157
+ var same = (prev === null && nextCrumb === null) ||
1158
+ (prev !== null && nextCrumb !== null && prev.path === nextCrumb.path && prev.key === nextCrumb.key && prev.idx === nextCrumb.idx);
1159
+ if (!same) {
1160
+ crumbRef.current = nextCrumb;
1161
+ setCrumb(nextCrumb);
1162
+ }
794
1163
  };
795
1164
  update();
796
1165
  sp.addEventListener("scroll", update, { passive: true });
@@ -835,6 +1204,46 @@ window.__ModuleLoader__.load({
835
1204
  return out;
836
1205
  }, [groups, query, searchScope]);
837
1206
 
1207
+ // which result row holds the current (n/N) occurrence (needs `matches`)
1208
+ var activeResultRow = -1;
1209
+ if (resultRows.length > 0 && matches.length > 0) {
1210
+ var acc = 0;
1211
+ var target = matchIdx % matches.length;
1212
+ for (var ri = 0; ri < resultRows.length; ri++) {
1213
+ acc += resultRows[ri].count;
1214
+ if (target < acc) { activeResultRow = ri; break; }
1215
+ }
1216
+ }
1217
+
1218
+ // The result list follows the outline's convention: OLDEST hit at the top,
1219
+ // NEWEST at the bottom, and a fresh search starts scrolled to the bottom —
1220
+ // scroll UP from there to walk back through earlier hits. The newest hit is
1221
+ // also made the current one, so the highlighted row is the one on screen
1222
+ // (otherwise n/N points at a row that is off-view).
1223
+ var searchPosRef = react.useRef("");
1224
+ react.useEffect(function () {
1225
+ var q = query.trim();
1226
+ if (searchPosRef.current === q) return;
1227
+ searchPosRef.current = q;
1228
+ if (!q) return;
1229
+ var el = listRef.current;
1230
+ if (el) el.scrollTop = el.scrollHeight;
1231
+ if (matches.length > 0) setMatchIdx(matches.length - 1);
1232
+ }, [query, matches.length]);
1233
+
1234
+ // keep the current row in view while stepping with Enter
1235
+ react.useEffect(function () {
1236
+ if (!query.trim() || activeResultRow < 0) return;
1237
+ var el = listRef.current;
1238
+ if (!el || !el.querySelector) return;
1239
+ var node = el.querySelector('[data-result-idx="' + activeResultRow + '"]');
1240
+ if (!node) return;
1241
+ var er = node.getBoundingClientRect();
1242
+ var lr2 = el.getBoundingClientRect();
1243
+ if (er.top < lr2.top + 2) el.scrollTop += er.top - lr2.top - 2;
1244
+ else if (er.bottom > lr2.bottom - 2) el.scrollTop += er.bottom - lr2.bottom + 2;
1245
+ }, [activeResultRow, query]);
1246
+
838
1247
  // ---- nothing to show without headings ----
839
1248
  if (groups.length === 0) return null;
840
1249
 
@@ -866,15 +1275,19 @@ window.__ModuleLoader__.load({
866
1275
  if (e.button !== 0) return;
867
1276
  var startY = e.clientY;
868
1277
  var origY = panelY;
1278
+ var lastY = panelY;
869
1279
  var move = function (ev) {
870
1280
  var next = origY + (ev.clientY - startY);
871
1281
  next = Math.max(-(viewport ? viewport.top : 0), Math.min(window.innerHeight - 90, next));
1282
+ lastY = next;
872
1283
  setPanelY(next);
873
1284
  };
874
1285
  var up = function () {
875
1286
  window.removeEventListener("pointermove", move);
876
1287
  window.removeEventListener("pointerup", up);
877
- try { localStorage.setItem(PANEL_Y_KEY, String(panelY)); } catch (e2) {}
1288
+ // persist the DRAGGED value: `panelY` here is the value captured when
1289
+ // the drag started (the state setter has not re-rendered this closure)
1290
+ try { localStorage.setItem(PANEL_Y_KEY, String(lastY)); } catch (e2) {}
878
1291
  };
879
1292
  window.addEventListener("pointermove", move);
880
1293
  window.addEventListener("pointerup", up);
@@ -950,6 +1363,15 @@ window.__ModuleLoader__.load({
950
1363
  }, 150);
951
1364
  };
952
1365
 
1366
+ // clicking result row `i` = make its FIRST occurrence the current match, so
1367
+ // it highlights + scrolls exactly like Enter stepping does (rows are deduped
1368
+ // one-per-message, so row i starts at the sum of the previous rows' counts)
1369
+ var goToRow = function (i) {
1370
+ var start = 0;
1371
+ for (var k = 0; k < i && k < resultRows.length; k++) start += resultRows[k].count;
1372
+ goToMatch(start);
1373
+ };
1374
+
953
1375
  // ---- circular icon button (glyph optical offset: ox/oy px) ----
954
1376
  var iconBtn = function (onClick, tip, glyph, fontSize, offset) {
955
1377
  var ox = offset ? (offset.x || 0) : 0;
@@ -1018,7 +1440,8 @@ window.__ModuleLoader__.load({
1018
1440
  panelClip = "inset(0 0 0 " + (panelW + 8) + "px)";
1019
1441
  }
1020
1442
  }
1021
- var panelOpacity = open ? (hovered ? 0.95 : 0.45) : 0;
1443
+ // faded out entirely while another center-column view is active
1444
+ var panelOpacity = open && chatViewActive ? (hovered ? 0.95 : 0.45) : 0;
1022
1445
 
1023
1446
  var panelEl = react_jsx_runtime.jsx("div", {
1024
1447
  style: {
@@ -1042,8 +1465,10 @@ window.__ModuleLoader__.load({
1042
1465
  // soft shade in light) — outer shadow would be clipped by the collapse
1043
1466
  // clip-path and the panel edges.
1044
1467
  boxShadow: innerShadow(),
1045
- transition: panelTransition,
1046
- pointerEvents: "auto"
1468
+ // A view switch swaps in a short, delay-free opacity fade; the dock and
1469
+ // collapse animations keep their own (slower) timings.
1470
+ transition: viewFading ? "opacity 0.3s ease" : panelTransition,
1471
+ pointerEvents: chatViewActive ? "auto" : "none"
1047
1472
  },
1048
1473
  onMouseEnter: function () { setHovered(true); },
1049
1474
  onMouseLeave: function () { setHovered(false); },
@@ -1115,6 +1540,29 @@ window.__ModuleLoader__.load({
1115
1540
  react_jsx_runtime.jsx("div", {
1116
1541
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
1117
1542
  children: [
1543
+ // collapse/expand the H1–H6 level-filter row (sits directly
1544
+ // left of the search button). Labelled, not a bare icon.
1545
+ react_jsx_runtime.jsx("button", {
1546
+ onClick: toggleLevelsRow,
1547
+ title: showLevelsRow ? "收起 H1–H6 层级筛选那一行" : "展开 H1–H6 层级筛选那一行",
1548
+ style: {
1549
+ height: 20,
1550
+ padding: "0 6px",
1551
+ border: "none",
1552
+ borderRadius: 5,
1553
+ cornerShape: "round",
1554
+ fontFamily: "inherit",
1555
+ fontSize: 11,
1556
+ lineHeight: "20px",
1557
+ cursor: "pointer",
1558
+ flex: "none",
1559
+ background: showLevelsRow
1560
+ ? "rgba(79,140,255,0.16)"
1561
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
1562
+ color: showLevelsRow ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted
1563
+ },
1564
+ children: "层级"
1565
+ }),
1118
1566
  // magnifier button (SVG, matches the other buttons' style)
1119
1567
  react_jsx_runtime.jsx("button", {
1120
1568
  onClick: function () { searchOpen ? closeSearch() : openSearch(); },
@@ -1206,6 +1654,67 @@ window.__ModuleLoader__.load({
1206
1654
  })
1207
1655
  ]
1208
1656
  }),
1657
+ // level filter row (H1–H6): can be collapsed away with the 层级 button
1658
+ // in the panel header
1659
+ showLevelsRow ? react_jsx_runtime.jsx("div", {
1660
+ style: {
1661
+ flex: "none",
1662
+ display: "flex",
1663
+ alignItems: "center",
1664
+ justifyContent: "space-between",
1665
+ flexWrap: "wrap",
1666
+ rowGap: 2,
1667
+ gap: 6,
1668
+ padding: "3px 10px 3px",
1669
+ borderBottom: crumb ? "none" : "1px solid " + C.panelBorder
1670
+ },
1671
+ children: [
1672
+ react_jsx_runtime.jsx("div", {
1673
+ style: { display: "flex", alignItems: "center", gap: 2, flex: "none" },
1674
+ children: [1, 2, 3, 4, 5, 6].map(function (lv) {
1675
+ var active = !!levelSet[lv];
1676
+ return react_jsx_runtime.jsx("button", {
1677
+ onClick: function () { toggleLevel(lv); },
1678
+ title: active ? "隐藏 H" + lv : "显示 H" + lv,
1679
+ style: {
1680
+ width: 17,
1681
+ height: 17,
1682
+ padding: 0,
1683
+ border: "none",
1684
+ borderRadius: 4,
1685
+ cornerShape: "round",
1686
+ cursor: "pointer",
1687
+ fontSize: 10,
1688
+ lineHeight: "17px",
1689
+ background: active
1690
+ ? "rgba(79,140,255,0.22)"
1691
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1692
+ color: active ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1693
+ fontWeight: active ? 600 : 400
1694
+ },
1695
+ children: lv
1696
+ }, "lv" + lv);
1697
+ })
1698
+ })
1699
+ ]
1700
+ }) : null,
1701
+ // breadcrumb: the heading path you are currently reading
1702
+ crumb ? react_jsx_runtime.jsx("div", {
1703
+ onClick: function () { jump(crumb.key, crumb.idx); },
1704
+ title: crumb.path,
1705
+ style: {
1706
+ flex: "none",
1707
+ padding: "0 10px 4px",
1708
+ borderBottom: "1px solid " + C.panelBorder,
1709
+ fontSize: 10,
1710
+ color: C.muted,
1711
+ whiteSpace: "nowrap",
1712
+ overflow: "hidden",
1713
+ textOverflow: "ellipsis",
1714
+ cursor: "pointer"
1715
+ },
1716
+ children: crumb.path
1717
+ }) : null,
1209
1718
  // search input row: outer grid row animates 0fr<->1fr so the row
1210
1719
  // collapses/expands to its EXACT natural height (no max-height
1211
1720
  // overshoot stutter); the outline below moves smoothly with it
@@ -1364,7 +1873,11 @@ window.__ModuleLoader__.load({
1364
1873
  className: "dqt-list",
1365
1874
  style: {
1366
1875
  overflowY: "auto",
1367
- padding: "6px 8px",
1876
+ // NO top padding: a sticky child is confined to its containing
1877
+ // block, so a padded scroll container would hold the pinned group
1878
+ // header that many pixels below the toolbar (a visible gap). Spacing
1879
+ // between groups comes from the per-group divider instead.
1880
+ padding: "0 8px 6px",
1368
1881
  flex: "1 1 auto",
1369
1882
  minHeight: 0,
1370
1883
  direction: dockRight ? "ltr" : "rtl"
@@ -1373,7 +1886,9 @@ window.__ModuleLoader__.load({
1373
1886
  onWheel: onListWheel,
1374
1887
  children: react_jsx_runtime.jsx("div", {
1375
1888
  style: { direction: "ltr" },
1376
- children: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
1889
+ children: query.trim()
1890
+ ? renderResults(resultRows, query.trim().toLowerCase(), C, goToRow, activeResultRow)
1891
+ : renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
1377
1892
  })
1378
1893
  }),
1379
1894
  // resize handles (right edge: width, bottom edge: height)
@@ -1436,7 +1951,9 @@ window.__ModuleLoader__.load({
1436
1951
  zIndex: Z_BASE,
1437
1952
  top: (viewport ? viewport.top + viewport.height / 2 - 46 : "50%"),
1438
1953
  transform: handleShown ? "translateX(0)" : (dockRight ? "translateX(16px)" : "translateX(-16px)"),
1439
- transition: "transform 0.4s " + EASE,
1954
+ opacity: chatViewActive ? 1 : 0,
1955
+ pointerEvents: chatViewActive ? "auto" : "none",
1956
+ transition: "transform 0.4s " + EASE + ", opacity 0.26s ease",
1440
1957
  ...(dockRight
1441
1958
  ? { right: viewport ? viewport.right + 52 : 60 } // clear of the milestone rail
1442
1959
  : { left: viewport ? viewport.left : 0 })
@@ -1483,6 +2000,12 @@ window.__ModuleLoader__.load({
1483
2000
  }
1484
2001
 
1485
2002
  function renderItem(n, depth, jump, C, uid) {
2003
+ var hasChildren = !!(n.children && n.children.length);
2004
+ var parts = [];
2005
+ parts.push(react_jsx_runtime.jsx("span", {
2006
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
2007
+ children: n.title
2008
+ }, uid + "-l"));
1486
2009
  return react_jsx_runtime.jsx(
1487
2010
  "div",
1488
2011
  {
@@ -1490,8 +2013,11 @@ window.__ModuleLoader__.load({
1490
2013
  "data-jump-key": n.key,
1491
2014
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
1492
2015
  style: {
2016
+ display: "flex",
2017
+ alignItems: "center",
2018
+ gap: 2,
1493
2019
  padding: "2px 6px",
1494
- paddingLeft: 8 + (n.level - 1) * 12,
2020
+ paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
1495
2021
  margin: "1px 0",
1496
2022
  borderRadius: 6,
1497
2023
  cursor: "pointer",
@@ -1501,13 +2027,12 @@ window.__ModuleLoader__.load({
1501
2027
  lineHeight: "18px",
1502
2028
  height: 22,
1503
2029
  whiteSpace: "nowrap",
1504
- overflow: "hidden",
1505
- textOverflow: "ellipsis"
2030
+ overflow: "hidden"
1506
2031
  },
1507
2032
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1508
2033
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1509
2034
  title: n.title,
1510
- children: n.title
2035
+ children: parts
1511
2036
  },
1512
2037
  uid + "-" + n.level + "-" + (n.key || "")
1513
2038
  );
@@ -1525,23 +2050,130 @@ window.__ModuleLoader__.load({
1525
2050
  return out;
1526
2051
  }
1527
2052
 
1528
- // a group's header: clicking the time jumps to that turn's start.
2053
+ // ---- search results view ----
2054
+ // While a query is present the list shows every matched heading/message
2055
+ // (grouped per message, with an occurrence count) instead of the outline, so
2056
+ // search is "see them all, then jump" rather than stepping blindly.
2057
+ function hitSpans(text, q) {
2058
+ var parts = highlightParts(text, q);
2059
+ return parts.map(function (p, i) {
2060
+ return p.hit
2061
+ ? react_jsx_runtime.jsx("span", {
2062
+ style: { background: "rgba(255,196,0,0.32)", borderRadius: 2, color: "inherit" },
2063
+ children: p.text
2064
+ }, "hp" + i)
2065
+ : p.text;
2066
+ });
2067
+ }
2068
+
2069
+ function renderResultRow(r, i, q, C, onRow, isActive) {
2070
+ var meta = [];
2071
+ if (r.time) meta.push(r.time);
2072
+ if (r.path) meta.push(r.path);
2073
+ if (r.count > 1) meta.push("×" + r.count);
2074
+ var children = [
2075
+ react_jsx_runtime.jsx("div", {
2076
+ style: {
2077
+ fontSize: 12,
2078
+ fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
2079
+ color: r.level > 0 ? C.text : C.muted,
2080
+ whiteSpace: "nowrap",
2081
+ overflow: "hidden",
2082
+ textOverflow: "ellipsis"
2083
+ },
2084
+ children: hitSpans(r.title, q)
2085
+ }, "t" + i)
2086
+ ];
2087
+ if (r.snippet) {
2088
+ children.push(react_jsx_runtime.jsx("div", {
2089
+ style: {
2090
+ fontSize: 11,
2091
+ color: C.muted,
2092
+ marginTop: 1,
2093
+ whiteSpace: "nowrap",
2094
+ overflow: "hidden",
2095
+ textOverflow: "ellipsis"
2096
+ },
2097
+ children: hitSpans(r.snippet, q)
2098
+ }, "s" + i));
2099
+ }
2100
+ if (meta.length) {
2101
+ children.push(react_jsx_runtime.jsx("div", {
2102
+ style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
2103
+ children: meta.join(" · ")
2104
+ }, "m" + i));
2105
+ }
2106
+ return react_jsx_runtime.jsx("div", {
2107
+ // clicking a row makes that hit the CURRENT one: it highlights the
2108
+ // keyword in the conversation and scrolls there, exactly like Enter
2109
+ // stepping — a row that only scrolled (no highlight) was the old bug
2110
+ onClick: function () { onRow(i); },
2111
+ "data-result-idx": i,
2112
+ onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2113
+ onMouseLeave: function (e) { e.currentTarget.style.background = isActive ? "rgba(79,140,255,0.16)" : "transparent"; },
2114
+ title: r.path || r.title,
2115
+ style: {
2116
+ padding: "4px 6px",
2117
+ margin: "1px 0",
2118
+ borderRadius: 6,
2119
+ cursor: "pointer",
2120
+ minWidth: 0,
2121
+ background: isActive ? "rgba(79,140,255,0.16)" : "transparent",
2122
+ transition: "background 0.15s ease"
2123
+ },
2124
+ children: children
2125
+ }, "res-" + i);
2126
+ }
2127
+
2128
+ function renderResults(rows, q, C, onRow, activeRow) {
2129
+ if (!rows || rows.length === 0) {
2130
+ return react_jsx_runtime.jsx("div", {
2131
+ style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
2132
+ children: "没有匹配"
2133
+ });
2134
+ }
2135
+ var out = [];
2136
+ for (var i = 0; i < rows.length; i++) {
2137
+ out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
2138
+ }
2139
+ return out;
2140
+ }
2141
+
2142
+ // a group's header (the time row): clicking it jumps to the TOP OF THE
2143
+ // MODEL'S REPLY for that turn — the first assistant step — which also makes
2144
+ // heading-less turns jumpable, since they only have this row.
1529
2145
  // Implemented as a function (not an inline closure in a loop) so each
1530
2146
  // header captures its own (g, gi) — the var-in-loop closure bug would
1531
2147
  // otherwise make every header jump to the last group.
1532
2148
  function renderGroupHeader(g, gi, jump, C) {
2149
+ var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
2150
+ var targetKey = replyKey || g.userKey;
1533
2151
  var jumpToTurn = function (e) {
1534
2152
  e.stopPropagation();
1535
- if (!g.userKey) return;
1536
- var r0 = findRowStrict(g.userKey);
1537
- if (r0) jump(g.userKey);
1538
- else if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
2153
+ // prefer the model's reply; fall back to the user message, then to the
2154
+ // turn's first heading, so the row always does something useful
2155
+ if (replyKey && findRow(replyKey)) { jump(replyKey); return; }
2156
+ if (g.userKey && findRow(g.userKey)) { jump(g.userKey); return; }
2157
+ if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
1539
2158
  };
1540
2159
  return react_jsx_runtime.jsx("div", {
1541
- style: { padding: "1px 4px 2px", height: 18, display: "flex", alignItems: "center", minWidth: 0 },
2160
+ style: {
2161
+ padding: "1px 4px 2px",
2162
+ height: 18,
2163
+ display: "flex",
2164
+ alignItems: "center",
2165
+ minWidth: 0,
2166
+ // sticky: while you scroll the outline, the header of the group you are
2167
+ // inside stays pinned at the top (background must be opaque to cover
2168
+ // the rows scrolling underneath).
2169
+ position: "sticky",
2170
+ top: 0,
2171
+ zIndex: 2,
2172
+ background: C.panelBg
2173
+ },
1542
2174
  children: react_jsx_runtime.jsx("span", {
1543
2175
  onClick: jumpToTurn,
1544
- title: g.userKey ? "跳转到该回合开头" : "",
2176
+ title: replyKey ? "跳转到该回合的模型回答开头" : "跳转到该回合开头",
1545
2177
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1546
2178
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1547
2179
  style: {
@@ -1611,7 +2243,7 @@ window.__ModuleLoader__.load({
1611
2243
  ctx.effect(function () {
1612
2244
  return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
1613
2245
  }, "dsh-quick-toc: dictionaries");
1614
- // DSH 0.1.2-rc.1: session-scoped hooks (useChat/useSession/sessionId) only arrive
2246
+ // Recent DSH (0.1.5-rc.1): session-scoped hooks (useChat/useSession/sessionId) only arrive
1615
2247
  // inside a declared session slot. Register the panel into the session-scoped
1616
2248
  // conversation.input.overlay (list/additive) so it receives useChat; the panel
1617
2249
  // itself renders a fixed, frame-floating dock, so the overlay seat is just the