dsh-quick-toc 0.1.0 → 0.2.0-beta

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,7 +17,13 @@ A quick conversation TOC plugin for [DeepSeek Harness](https://github.com/deepse
17
17
 
18
18
  ## Install
19
19
 
20
- With the DSH CLI:
20
+ With the DSH CLI (published on npm — name only):
21
+
22
+ ```
23
+ dsh plugin --profile web add dsh-quick-toc
24
+ ```
25
+
26
+ or from GitHub:
21
27
 
22
28
  ```
23
29
  dsh plugin --profile web add github:LyaxZ/dsh-quick-toc
package/README.md CHANGED
@@ -17,7 +17,13 @@
17
17
 
18
18
  ## 安装
19
19
 
20
- 通过 DSH CLI 安装(仓库路径替换为实际值):
20
+ 通过 DSH CLI 安装(已发布 npm,只要名字):
21
+
22
+ ```
23
+ dsh plugin --profile web add dsh-quick-toc
24
+ ```
25
+
26
+ 或从 GitHub 安装:
21
27
 
22
28
  ```
23
29
  dsh plugin --profile web add github:LyaxZ/dsh-quick-toc
package/lib/client.js CHANGED
@@ -42,6 +42,34 @@ window.__ModuleLoader__.load({
42
42
  .trim();
43
43
  }
44
44
 
45
+ // user messages carry their text in data.content (assistant replies use
46
+ // data.blocks) — read whichever is present
47
+ function extractUserText(node) {
48
+ var d = node && node.data;
49
+ if (!d) return "";
50
+ var content = d.content;
51
+ if (typeof content === "string") return content;
52
+ if (Array.isArray(content)) {
53
+ var out = "";
54
+ for (var i = 0; i < content.length; i++) {
55
+ var b = content[i];
56
+ if (b && typeof b === "object" && (b.type === "text" || b.kind === "text") && typeof b.text === "string") {
57
+ out += b.text + "\n";
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ return extractReplyText(node);
63
+ }
64
+
65
+ // first non-empty line of a message, truncated for the outline header
66
+ function previewText(text, max) {
67
+ if (!text) return "";
68
+ var first = text.split("\n").map(function (s) { return s.trim(); }).filter(Boolean)[0] || "";
69
+ if (first.length > max) first = first.slice(0, max) + "…";
70
+ return first;
71
+ }
72
+
45
73
  function parseHeadings(text) {
46
74
  var items = [];
47
75
  var re = /^(#{1,6})\s+(.+?)\s*#*\s*$/gm;
@@ -65,6 +93,28 @@ window.__ModuleLoader__.load({
65
93
  return root.children;
66
94
  }
67
95
 
96
+ // strict lookup: exact dataset match or CSS-escaped selector only —
97
+ // no fuzzy contains matching (avoids landing on the wrong row);
98
+ // hidden rows (zero rect, e.g. duplicate/hidden copies) are skipped
99
+ function findRowStrict(key) {
100
+ if (!key) return null;
101
+ var rows = document.querySelectorAll("[data-chat-anchor-key]");
102
+ for (var i = 0; i < rows.length; i++) {
103
+ if (rows[i].dataset && rows[i].dataset.chatAnchorKey === key) {
104
+ var r = rows[i].getBoundingClientRect();
105
+ if (r.width > 0 && r.height > 0) return rows[i];
106
+ }
107
+ }
108
+ try {
109
+ var el = document.querySelector('[data-chat-anchor-key="' + window.CSS.escape(key) + '"]');
110
+ if (el) {
111
+ var r2 = el.getBoundingClientRect();
112
+ if (r2.width > 0 && r2.height > 0) return el;
113
+ }
114
+ } catch (e) {}
115
+ return null;
116
+ }
117
+
68
118
  function findRow(key) {
69
119
  if (!key) return null;
70
120
  var rows = document.querySelectorAll("[data-chat-anchor-key]");
@@ -85,6 +135,19 @@ window.__ModuleLoader__.load({
85
135
  return null;
86
136
  }
87
137
 
138
+ // find the conversation's "load older messages" button (scoped to the
139
+ // conversation scrollport so panel buttons are never matched)
140
+ function findLoadOlderButton() {
141
+ var sp = document.querySelector("[data-conversation-scroll]");
142
+ if (!sp) return null;
143
+ var buttons = sp.querySelectorAll("button");
144
+ for (var i = 0; i < buttons.length; i++) {
145
+ var t = (buttons[i].textContent || "").trim();
146
+ if (/加载|更早|loadOlder|older/i.test(t)) return buttons[i];
147
+ }
148
+ return null;
149
+ }
150
+
88
151
  // ---------- theme colors: DSH CSS variables (adapt to light/dark) ----------
89
152
  var C = {
90
153
  panelBg: "var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96))",
@@ -257,6 +320,58 @@ window.__ModuleLoader__.load({
257
320
  var setVisibleCount = _s9[1];
258
321
  var listRef = react.useRef(null);
259
322
  var didInitScroll = react.useRef(false);
323
+ var outlineTouchRef = react.useRef(0); // last time the user touched the outline
324
+
325
+ // ---- search: header button opens a keyword box; Enter cycles through
326
+ // matching headings and jumps to each ----
327
+ var _s13 = react.useState(false);
328
+ var searchOpen = _s13[0];
329
+ var setSearchOpen = _s13[1];
330
+ var _s14 = react.useState("");
331
+ var query = _s14[0];
332
+ var setQuery = _s14[1];
333
+ var _s15 = react.useState(0);
334
+ var matchIdx = _s15[0];
335
+ var setMatchIdx = _s15[1];
336
+ var searchRef = react.useRef(null);
337
+ // animation state: hidden | enter | shown | out; and a transient
338
+ // "searching" flag that drives the spinner
339
+ var _s16 = react.useState("hidden");
340
+ var searchAnim = _s16[0];
341
+ var setSearchAnim = _s16[1];
342
+ var _s17 = react.useState(false);
343
+ var searching = _s17[0];
344
+ var setSearching = _s17[1];
345
+ var searchTimerRef = react.useRef(null);
346
+ var openSearch = function () {
347
+ setSearchOpen(true);
348
+ setSearchAnim("enter");
349
+ };
350
+ var closeSearch = function () {
351
+ setSearchAnim("out");
352
+ setQuery("");
353
+ setTimeout(function () {
354
+ setSearchOpen(false);
355
+ setSearchAnim("hidden");
356
+ }, 240);
357
+ };
358
+ react.useEffect(function () {
359
+ if (searchAnim === "enter") {
360
+ var raf = requestAnimationFrame(function () { setSearchAnim("shown"); });
361
+ return function () { cancelAnimationFrame(raf); };
362
+ }
363
+ return undefined;
364
+ }, [searchAnim]);
365
+ react.useEffect(function () {
366
+ if (searchOpen && searchRef.current) searchRef.current.focus();
367
+ }, [searchOpen]);
368
+ var onQueryChange = function (v) {
369
+ setQuery(v);
370
+ setMatchIdx(0);
371
+ setSearching(true);
372
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
373
+ searchTimerRef.current = setTimeout(function () { setSearching(false); }, 220);
374
+ };
260
375
 
261
376
  // the group currently being read (under the list viewport middle):
262
377
  // it stays at full opacity, every other group is dimmed
@@ -272,20 +387,58 @@ window.__ModuleLoader__.load({
272
387
  listRef.current.scrollTop = listRef.current.scrollHeight;
273
388
  }, [groups ? groups.length : 0]);
274
389
 
275
- // scroll to the top edge loads older groups (keeps the visual position)
276
- var onListScroll = function (e) {
277
- var el = e.currentTarget;
278
- if (el.scrollTop <= 24 && visibleCount < groups.length) {
279
- var prevH = el.scrollHeight;
280
- setVisibleCount(Math.min(groups.length, visibleCount + PAGE_SIZE));
281
- requestAnimationFrame(function () {
390
+ // load older outline groups; when everything is loaded, click the
391
+ // conversation's own "load older" button so older turns keep appearing
392
+ var loadOlderOutline = function (el) {
393
+ var grow = function () {
394
+ if (visibleCount < groups.length) {
395
+ var prevH = el.scrollHeight;
396
+ setVisibleCount(Math.min(groups.length, visibleCount + PAGE_SIZE));
282
397
  requestAnimationFrame(function () {
283
- el.scrollTop += (el.scrollHeight - prevH);
398
+ requestAnimationFrame(function () {
399
+ el.scrollTop += (el.scrollHeight - prevH);
400
+ });
284
401
  });
285
- });
402
+ return true;
403
+ }
404
+ return false;
405
+ };
406
+ if (grow()) return;
407
+ var btn = findLoadOlderButton();
408
+ if (btn && !btn.disabled) {
409
+ btn.click();
410
+ // once the conversation loads more, expand the outline window too
411
+ setTimeout(function () {
412
+ var prevH2 = el.scrollHeight;
413
+ if (grow()) {
414
+ requestAnimationFrame(function () {
415
+ requestAnimationFrame(function () {
416
+ el.scrollTop += (el.scrollHeight - prevH2);
417
+ });
418
+ });
419
+ }
420
+ }, 1200);
286
421
  }
287
422
  };
288
423
 
424
+ // wheel up (toward older) loads more when the list is at its top or has
425
+ // nothing to scroll (content shorter than the panel) — so scrolling up
426
+ // always refreshes older turns, even without a visible scrollbar
427
+ var onListWheel = function (e) {
428
+ outlineTouchRef.current = Date.now();
429
+ if (e.deltaY >= 0) return;
430
+ var el = listRef.current;
431
+ if (!el) return;
432
+ if (el.scrollTop <= 1) loadOlderOutline(el);
433
+ };
434
+
435
+ // scroll to the top edge also loads older groups (keeps the visual position)
436
+ var onListScroll = function (e) {
437
+ outlineTouchRef.current = Date.now();
438
+ var el = e.currentTarget;
439
+ if (el.scrollTop <= 24) loadOlderOutline(el);
440
+ };
441
+
289
442
  // ---- resize drags (right edge = width, bottom edge = height) ----
290
443
  var onResizeWDown = function (e) {
291
444
  e.preventDefault();
@@ -390,8 +543,11 @@ window.__ModuleLoader__.load({
390
543
  var result = [];
391
544
  var current = null;
392
545
  var turnTimes = {};
546
+ var turnUserKey = {};
547
+ var turnUserText = {};
393
548
  if (!order || !nodes) return result;
394
- // first pass: per-turn time — the LAST message of the turn wins (end time)
549
+ // 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
395
551
  for (var i = 0; i < order.length; i++) {
396
552
  var k0 = order[i];
397
553
  var n0 = nodes.get(k0);
@@ -399,6 +555,10 @@ window.__ModuleLoader__.load({
399
555
  var l0 = n0.location;
400
556
  var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
401
557
  if (tid0 === null) continue;
558
+ if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
559
+ turnUserKey[tid0] = k0;
560
+ turnUserText[tid0] = previewText(extractUserText(n0), 30);
561
+ }
402
562
  var t0 = getNodeTime(n0);
403
563
  if (t0) turnTimes[tid0] = t0;
404
564
  }
@@ -411,7 +571,13 @@ window.__ModuleLoader__.load({
411
571
  var turnId = loc && (loc.kind === "turn" || loc.kind === "step") && loc.turn ? loc.turn.turn : null;
412
572
  var sameGroup = current !== null && current.turn === turnId;
413
573
  if (!sameGroup) {
414
- current = { turn: turnId, time: turnId !== null ? (turnTimes[turnId] || "") : "", headings: [] };
574
+ current = {
575
+ turn: turnId,
576
+ time: turnId !== null ? (turnTimes[turnId] || "") : "",
577
+ userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
578
+ userText: turnId !== null ? (turnUserText[turnId] || "") : "",
579
+ headings: []
580
+ };
415
581
  result.push(current);
416
582
  }
417
583
  var parsed = parseHeadings(extractReplyText(node));
@@ -421,7 +587,9 @@ window.__ModuleLoader__.load({
421
587
  hIdx++;
422
588
  }
423
589
  }
424
- return result.filter(function (g) { return g.headings.length > 0; });
590
+ // keep a turn when it has headings OR a time — turns without headings
591
+ // still get a standalone time entry in the outline (click to jump)
592
+ return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
425
593
  }, [order, nodes]);
426
594
  var groupTrees = react.useMemo(function () {
427
595
  return groups.map(function (g) { return buildTree(g.headings); });
@@ -463,8 +631,10 @@ window.__ModuleLoader__.load({
463
631
  if (sig !== activeSigRef.current) {
464
632
  activeSigRef.current = sig;
465
633
  setActiveGroup(actives);
466
- // auto-follow: keep the reading position visible in the outline
467
- if (actives.length > 0) {
634
+ // auto-follow: keep the reading position visible in the outline
635
+ // but pause for ~2s after the user touches the outline themselves,
636
+ // otherwise loading older turns gets yanked back to the bottom
637
+ if (actives.length > 0 && Date.now() - outlineTouchRef.current > 2000) {
468
638
  var gi0 = actives[0];
469
639
  // ensure the group's window is loaded (with a small buffer below)
470
640
  setVisibleCount(function (prev) {
@@ -491,6 +661,23 @@ window.__ModuleLoader__.load({
491
661
  return function () { sp.removeEventListener("scroll", update); };
492
662
  }, [keyToGroup]);
493
663
 
664
+ // ---- search matches: headings whose title contains the query ----
665
+ var matches = react.useMemo(function () {
666
+ var q = query.trim().toLowerCase();
667
+ if (!q) return [];
668
+ var out = [];
669
+ for (var gi = 0; gi < groups.length; gi++) {
670
+ var g = groups[gi];
671
+ for (var j = 0; j < g.headings.length; j++) {
672
+ var h = g.headings[j];
673
+ if (h.title.toLowerCase().indexOf(q) >= 0) {
674
+ out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
675
+ }
676
+ }
677
+ }
678
+ return out;
679
+ }, [groups, query]);
680
+
494
681
  // ---- nothing to show without headings ----
495
682
  if (groups.length === 0) return null;
496
683
 
@@ -502,7 +689,19 @@ window.__ModuleLoader__.load({
502
689
  ? window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW
503
690
  : (viewport ? viewport.left + 8 : 8);
504
691
  } else {
505
- panelLeft = dockRight ? window.innerWidth + 24 : -(panelW + 48);
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
+ }
506
705
  }
507
706
  var vMaxH = viewport ? Math.max(200, viewport.height - 28) : "72vh";
508
707
  var baseTopPx = (viewport ? viewport.top + 14 : window.innerHeight * 0.12) + panelY;
@@ -555,6 +754,24 @@ window.__ModuleLoader__.load({
555
754
  sp.scrollTo({ top: t, behavior: "smooth" });
556
755
  };
557
756
 
757
+ // jump to the n-th match, cycling; also reveal the group in the outline
758
+ var goToMatch = function (n) {
759
+ if (matches.length === 0) return;
760
+ var i = ((n % matches.length) + matches.length) % matches.length;
761
+ setMatchIdx(i);
762
+ var m = matches[i];
763
+ jump(m.key, m.idx);
764
+ setVisibleCount(function (prev) {
765
+ return Math.max(prev, Math.min(groups.length, groups.length - m.gi));
766
+ });
767
+ setTimeout(function () {
768
+ var el = listRef.current;
769
+ if (!el) return;
770
+ var node = el.querySelector('[data-group-idx="' + m.gi + '"]');
771
+ if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
772
+ }, 150);
773
+ };
774
+
558
775
  // ---- circular icon button (glyph optical offset: ox/oy px) ----
559
776
  var iconBtn = function (onClick, tip, glyph, fontSize, offset) {
560
777
  var ox = offset ? (offset.x || 0) : 0;
@@ -599,8 +816,26 @@ window.__ModuleLoader__.load({
599
816
  };
600
817
 
601
818
  // ---- panel ----
602
- // panel: non-linear slide with fade in/out, slightly slower for a smoother feel
603
- var panelTransition = "left 0.6s " + EASE + ", opacity 0.45s ease";
819
+ // panel: opening slides in with a slow fade; closing slides quickly to
820
+ // the dock edge, clipped by the sidebar line (looks covered, not
821
+ // dissolving) and only fades at the very end. No box-shadow: a shadow
822
+ // would get cut in half by the clip-path, so the panel is flat.
823
+ var panelTransition = open
824
+ ? "left 0.6s " + EASE + ", clip-path 0.6s " + EASE + ", opacity 0.45s ease"
825
+ : "left 0.28s " + EASE + ", clip-path 0.28s " + EASE + ", opacity 0.14s ease 0.26s";
826
+
827
+ // clip the panel at the dock edge while collapsed, so sliding away looks
828
+ // like being covered by the sidebar (the sidebar stays untouched):
829
+ // left dock -> clipped from the left up to the sidebar line
830
+ // right dock -> clipped from the right at the screen/sidebar line
831
+ var panelClip = "inset(0 0 0 0px)";
832
+ if (!open) {
833
+ if (dockRight) {
834
+ panelClip = "inset(0 " + panelW + "px 0 0)";
835
+ } else {
836
+ panelClip = "inset(0 0 0 " + (panelW + 8) + "px)";
837
+ }
838
+ }
604
839
  var panelOpacity = open ? (hovered ? 0.95 : 0.45) : 0;
605
840
 
606
841
  var panelEl = react_jsx_runtime.jsx("div", {
@@ -621,6 +856,8 @@ window.__ModuleLoader__.load({
621
856
  overflow: "hidden",
622
857
  color: C.text,
623
858
  opacity: panelOpacity,
859
+ clipPath: panelClip,
860
+ boxShadow: "none",
624
861
  transition: panelTransition,
625
862
  pointerEvents: "auto"
626
863
  },
@@ -693,15 +930,164 @@ window.__ModuleLoader__.load({
693
930
  react_jsx_runtime.jsx("div", {
694
931
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
695
932
  children: [
696
- // triangle tips toward the side it will move TO: shift slightly down + toward the tip
697
- iconBtn(toggleDock, dockRight ? "移到左侧" : "移到右侧", dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: 1 } : { x: 1, y: 1 }),
698
- iconBtn(function () { setOpen(false); }, "收起", "✕", 13, { x: 0, y: -1 })
933
+ // magnifier button (SVG, matches the other buttons' style)
934
+ react_jsx_runtime.jsx("button", {
935
+ onClick: function () { searchOpen ? closeSearch() : openSearch(); },
936
+ title: "搜索标题",
937
+ style: {
938
+ width: 24,
939
+ height: 24,
940
+ display: "flex",
941
+ alignItems: "center",
942
+ justifyContent: "center",
943
+ borderRadius: "50%",
944
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
945
+ border: "none",
946
+ color: C.muted,
947
+ cursor: "pointer"
948
+ },
949
+ onMouseEnter: function (e) {
950
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
951
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
952
+ },
953
+ onMouseLeave: function (e) {
954
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
955
+ e.currentTarget.style.color = C.muted;
956
+ },
957
+ children: react_jsx_runtime.jsx("svg", {
958
+ width: 15,
959
+ height: 15,
960
+ viewBox: "0 0 24 24",
961
+ // overflow visible: the stroke may extend past the
962
+ // viewBox without being clipped at the svg boundary
963
+ style: { display: "block", transform: "translate(-1px, -1px)", overflow: "visible" },
964
+ fill: "none",
965
+ stroke: "currentColor",
966
+ strokeWidth: 3.6,
967
+ strokeLinecap: "round",
968
+ children: [
969
+ react_jsx_runtime.jsx("circle", { cx: 11, cy: 11, r: 7 }),
970
+ react_jsx_runtime.jsx("line", { x1: 21, y1: 21, x2: 16, y2: 16 })
971
+ ]
972
+ })
973
+ }),
974
+ // triangle tips toward the side it will move TO: shift slightly up + toward the tip
975
+ iconBtn(toggleDock, dockRight ? "移到左侧" : "移到右侧", dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 }),
976
+ // close: thick SVG cross, nudged slightly down
977
+ react_jsx_runtime.jsx("button", {
978
+ onClick: function () { setOpen(false); },
979
+ title: "收起",
980
+ style: {
981
+ width: 24,
982
+ height: 24,
983
+ display: "flex",
984
+ alignItems: "center",
985
+ justifyContent: "center",
986
+ borderRadius: "50%",
987
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
988
+ border: "none",
989
+ color: C.muted,
990
+ cursor: "pointer"
991
+ },
992
+ onMouseEnter: function (e) {
993
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
994
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
995
+ },
996
+ onMouseLeave: function (e) {
997
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
998
+ e.currentTarget.style.color = C.muted;
999
+ },
1000
+ children: react_jsx_runtime.jsx("svg", {
1001
+ width: 15,
1002
+ height: 15,
1003
+ viewBox: "0 0 24 24",
1004
+ style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
1005
+ fill: "none",
1006
+ stroke: "currentColor",
1007
+ strokeWidth: 3.4,
1008
+ strokeLinecap: "round",
1009
+ children: [
1010
+ // cross lines span 5..19, intersection (12,12) centered
1011
+ react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
1012
+ react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
1013
+ ]
1014
+ })
1015
+ })
699
1016
  ]
700
1017
  })
701
1018
  ]
702
1019
  })
703
1020
  ]
704
1021
  }),
1022
+ // search input row: outer grid row animates 0fr<->1fr so the row
1023
+ // collapses/expands to its EXACT natural height (no max-height
1024
+ // overshoot stutter); the outline below moves smoothly with it
1025
+ (searchOpen || searchAnim === "out") ? react_jsx_runtime.jsx("div", {
1026
+ style: {
1027
+ display: "grid",
1028
+ gridTemplateRows: (searchAnim === "enter" || searchAnim === "out") ? "0fr" : "1fr",
1029
+ transition: "grid-template-rows 0.2s " + EASE,
1030
+ flex: "none"
1031
+ },
1032
+ children: react_jsx_runtime.jsx("div", {
1033
+ style: { overflow: "hidden", minHeight: 0 },
1034
+ children: react_jsx_runtime.jsx("div", {
1035
+ style: {
1036
+ display: "flex",
1037
+ alignItems: "center",
1038
+ gap: 4,
1039
+ padding: "2px 8px 4px",
1040
+ borderBottom: "1px solid " + C.panelBorder,
1041
+ opacity: (searchAnim === "enter" || searchAnim === "out") ? 0 : 1,
1042
+ transform: (searchAnim === "enter" || searchAnim === "out") ? "translateY(-6px)" : "none",
1043
+ transition: "opacity 0.2s ease, transform 0.2s ease"
1044
+ },
1045
+ 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: "搜索标题,回车定位…",
1055
+ style: {
1056
+ flex: 1,
1057
+ minWidth: 0,
1058
+ 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
+ }
1066
+ }),
1067
+ searching ? react_jsx_runtime.jsx("span", {
1068
+ style: { width: 13, height: 13, flex: "none", display: "flex", alignItems: "center", justifyContent: "center" },
1069
+ children: react_jsx_runtime.jsx("svg", {
1070
+ width: 12,
1071
+ height: 12,
1072
+ viewBox: "0 0 24 24",
1073
+ style: { display: "block", animation: "dqt-spin 0.8s linear infinite" },
1074
+ children: react_jsx_runtime.jsx("circle", {
1075
+ cx: 12, cy: 12, r: 9,
1076
+ fill: "none",
1077
+ stroke: C.muted,
1078
+ strokeWidth: 3,
1079
+ strokeDasharray: "42 22",
1080
+ strokeLinecap: "round"
1081
+ })
1082
+ })
1083
+ }) : react_jsx_runtime.jsx("span", {
1084
+ style: { fontSize: 11, color: C.muted, flex: "none", minWidth: 30, textAlign: "center" },
1085
+ children: matches.length > 0 ? ((matchIdx % matches.length) + 1) + "/" + matches.length : ""
1086
+ })
1087
+ ]
1088
+ })
1089
+ })
1090
+ }) : null,
705
1091
  // outline list (paged: newest first, scroll to the top loads older);
706
1092
  // the scrollbar follows the dock side (rtl flips it to the left)
707
1093
  react_jsx_runtime.jsx("div", {
@@ -709,12 +1095,12 @@ window.__ModuleLoader__.load({
709
1095
  style: {
710
1096
  overflowY: "auto",
711
1097
  padding: "6px 8px",
712
- maxHeight: "60vh",
713
1098
  flex: "1 1 auto",
714
1099
  minHeight: 0,
715
1100
  direction: dockRight ? "ltr" : "rtl"
716
1101
  },
717
1102
  onScroll: onListScroll,
1103
+ onWheel: onListWheel,
718
1104
  children: react_jsx_runtime.jsx("div", {
719
1105
  style: { direction: "ltr" },
720
1106
  children: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
@@ -867,6 +1253,50 @@ window.__ModuleLoader__.load({
867
1253
  return out;
868
1254
  }
869
1255
 
1256
+ // a group's header: clicking the time jumps to that turn's start.
1257
+ // Implemented as a function (not an inline closure in a loop) so each
1258
+ // header captures its own (g, gi) — the var-in-loop closure bug would
1259
+ // otherwise make every header jump to the last group.
1260
+ function renderGroupHeader(g, gi, jump, C) {
1261
+ var jumpToTurn = function (e) {
1262
+ e.stopPropagation();
1263
+ if (!g.userKey) return;
1264
+ var r0 = findRowStrict(g.userKey);
1265
+ if (r0) jump(g.userKey);
1266
+ else if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
1267
+ };
1268
+ return react_jsx_runtime.jsx("div", {
1269
+ style: { padding: "1px 4px 2px", height: 18, display: "flex", alignItems: "center", minWidth: 0 },
1270
+ children: react_jsx_runtime.jsx("span", {
1271
+ onClick: jumpToTurn,
1272
+ title: g.userKey ? "跳转到该回合开头" : "",
1273
+ onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1274
+ onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1275
+ style: {
1276
+ fontSize: 11,
1277
+ color: C.muted,
1278
+ cursor: "pointer",
1279
+ padding: "1px 5px",
1280
+ borderRadius: 4,
1281
+ transition: "background 0.15s ease",
1282
+ display: "inline-flex",
1283
+ alignItems: "center",
1284
+ gap: 5,
1285
+ minWidth: 0,
1286
+ maxWidth: "100%",
1287
+ overflow: "hidden"
1288
+ },
1289
+ children: [
1290
+ react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
1291
+ g.userText ? react_jsx_runtime.jsx("span", {
1292
+ style: { fontWeight: 400, opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
1293
+ children: g.userText
1294
+ }) : null
1295
+ ]
1296
+ })
1297
+ }, "g-h-" + gi);
1298
+ }
1299
+
870
1300
  // render each conversation turn as its own block: solid divider + time header
871
1301
  // offset = global group index of the first rendered group (stable React keys)
872
1302
  // activeIdx = array of groups being read (full opacity); others are dimmed
@@ -884,19 +1314,12 @@ window.__ModuleLoader__.load({
884
1314
  items.push(react_jsx_runtime.jsx("div", {
885
1315
  style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
886
1316
  }, "g-sep-" + gi));
887
- if (g.time) {
888
- items.push(react_jsx_runtime.jsx("div", {
889
- style: { padding: "1px 4px 2px", fontSize: 11, color: C.muted, fontWeight: 600, lineHeight: "15px", height: 18 },
890
- children: g.time
891
- }, "g-h-" + gi));
892
- } else {
893
- items.push(react_jsx_runtime.jsx("div", { style: { height: 18 }, children: null }, "g-h-" + gi));
894
- }
1317
+ items.push(renderGroupHeader(g, gi, jump, C));
895
1318
  items.push(renderNodes(trees[i], 0, jump, C, "g" + gi));
896
1319
  var dim = !activeSet[gi];
897
1320
  out.push(react_jsx_runtime.jsx("div", {
898
1321
  "data-group-idx": gi,
899
- style: { opacity: dim ? 0.55 : 1, transition: "opacity 0.3s ease" },
1322
+ style: { opacity: dim ? 0.6 : 1, transition: "opacity 0.3s ease" },
900
1323
  children: items
901
1324
  }, "g-" + gi));
902
1325
  }
@@ -943,6 +1366,17 @@ window.__ModuleLoader__.load({
943
1366
 
944
1367
  exports.apply = apply;
945
1368
  exports.inject = inject;
1369
+
1370
+ // inject the small keyframe stylesheet once (spinner rotation)
1371
+ (function () {
1372
+ if (typeof document === "undefined") return;
1373
+ if (document.getElementById("dsh-quick-toc-css")) return;
1374
+ var s = document.createElement("style");
1375
+ s.id = "dsh-quick-toc-css";
1376
+ s.textContent = "@keyframes dqt-spin{to{transform:rotate(360deg)}}";
1377
+ document.head.appendChild(s);
1378
+ })();
1379
+
946
1380
  return module.exports;
947
1381
  }
948
1382
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta",
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",
@@ -39,6 +39,6 @@
39
39
  ],
40
40
  "repository": {
41
41
  "type": "git",
42
- "url": "https://github.com/LyaxZ/dsh-quick-toc.git"
42
+ "url": "git+https://github.com/LyaxZ/dsh-quick-toc.git"
43
43
  }
44
44
  }