dsh-quick-toc 0.1.0 → 0.1.1

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,7 @@ 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
260
324
 
261
325
  // the group currently being read (under the list viewport middle):
262
326
  // it stays at full opacity, every other group is dimmed
@@ -272,20 +336,58 @@ window.__ModuleLoader__.load({
272
336
  listRef.current.scrollTop = listRef.current.scrollHeight;
273
337
  }, [groups ? groups.length : 0]);
274
338
 
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 () {
339
+ // load older outline groups; when everything is loaded, click the
340
+ // conversation's own "load older" button so older turns keep appearing
341
+ var loadOlderOutline = function (el) {
342
+ var grow = function () {
343
+ if (visibleCount < groups.length) {
344
+ var prevH = el.scrollHeight;
345
+ setVisibleCount(Math.min(groups.length, visibleCount + PAGE_SIZE));
282
346
  requestAnimationFrame(function () {
283
- el.scrollTop += (el.scrollHeight - prevH);
347
+ requestAnimationFrame(function () {
348
+ el.scrollTop += (el.scrollHeight - prevH);
349
+ });
284
350
  });
285
- });
351
+ return true;
352
+ }
353
+ return false;
354
+ };
355
+ if (grow()) return;
356
+ var btn = findLoadOlderButton();
357
+ if (btn && !btn.disabled) {
358
+ btn.click();
359
+ // once the conversation loads more, expand the outline window too
360
+ setTimeout(function () {
361
+ var prevH2 = el.scrollHeight;
362
+ if (grow()) {
363
+ requestAnimationFrame(function () {
364
+ requestAnimationFrame(function () {
365
+ el.scrollTop += (el.scrollHeight - prevH2);
366
+ });
367
+ });
368
+ }
369
+ }, 1200);
286
370
  }
287
371
  };
288
372
 
373
+ // wheel up (toward older) loads more when the list is at its top or has
374
+ // nothing to scroll (content shorter than the panel) — so scrolling up
375
+ // always refreshes older turns, even without a visible scrollbar
376
+ var onListWheel = function (e) {
377
+ outlineTouchRef.current = Date.now();
378
+ if (e.deltaY >= 0) return;
379
+ var el = listRef.current;
380
+ if (!el) return;
381
+ if (el.scrollTop <= 1) loadOlderOutline(el);
382
+ };
383
+
384
+ // scroll to the top edge also loads older groups (keeps the visual position)
385
+ var onListScroll = function (e) {
386
+ outlineTouchRef.current = Date.now();
387
+ var el = e.currentTarget;
388
+ if (el.scrollTop <= 24) loadOlderOutline(el);
389
+ };
390
+
289
391
  // ---- resize drags (right edge = width, bottom edge = height) ----
290
392
  var onResizeWDown = function (e) {
291
393
  e.preventDefault();
@@ -390,8 +492,11 @@ window.__ModuleLoader__.load({
390
492
  var result = [];
391
493
  var current = null;
392
494
  var turnTimes = {};
495
+ var turnUserKey = {};
496
+ var turnUserText = {};
393
497
  if (!order || !nodes) return result;
394
- // first pass: per-turn time — the LAST message of the turn wins (end time)
498
+ // first pass: per-turn time — the LAST message of the turn wins (end time);
499
+ // also remember each turn's user message key + first-line preview
395
500
  for (var i = 0; i < order.length; i++) {
396
501
  var k0 = order[i];
397
502
  var n0 = nodes.get(k0);
@@ -399,6 +504,10 @@ window.__ModuleLoader__.load({
399
504
  var l0 = n0.location;
400
505
  var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
401
506
  if (tid0 === null) continue;
507
+ if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
508
+ turnUserKey[tid0] = k0;
509
+ turnUserText[tid0] = previewText(extractUserText(n0), 30);
510
+ }
402
511
  var t0 = getNodeTime(n0);
403
512
  if (t0) turnTimes[tid0] = t0;
404
513
  }
@@ -411,7 +520,13 @@ window.__ModuleLoader__.load({
411
520
  var turnId = loc && (loc.kind === "turn" || loc.kind === "step") && loc.turn ? loc.turn.turn : null;
412
521
  var sameGroup = current !== null && current.turn === turnId;
413
522
  if (!sameGroup) {
414
- current = { turn: turnId, time: turnId !== null ? (turnTimes[turnId] || "") : "", headings: [] };
523
+ current = {
524
+ turn: turnId,
525
+ time: turnId !== null ? (turnTimes[turnId] || "") : "",
526
+ userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
527
+ userText: turnId !== null ? (turnUserText[turnId] || "") : "",
528
+ headings: []
529
+ };
415
530
  result.push(current);
416
531
  }
417
532
  var parsed = parseHeadings(extractReplyText(node));
@@ -421,7 +536,9 @@ window.__ModuleLoader__.load({
421
536
  hIdx++;
422
537
  }
423
538
  }
424
- return result.filter(function (g) { return g.headings.length > 0; });
539
+ // keep a turn when it has headings OR a time — turns without headings
540
+ // still get a standalone time entry in the outline (click to jump)
541
+ return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
425
542
  }, [order, nodes]);
426
543
  var groupTrees = react.useMemo(function () {
427
544
  return groups.map(function (g) { return buildTree(g.headings); });
@@ -463,8 +580,10 @@ window.__ModuleLoader__.load({
463
580
  if (sig !== activeSigRef.current) {
464
581
  activeSigRef.current = sig;
465
582
  setActiveGroup(actives);
466
- // auto-follow: keep the reading position visible in the outline
467
- if (actives.length > 0) {
583
+ // auto-follow: keep the reading position visible in the outline
584
+ // but pause for ~2s after the user touches the outline themselves,
585
+ // otherwise loading older turns gets yanked back to the bottom
586
+ if (actives.length > 0 && Date.now() - outlineTouchRef.current > 2000) {
468
587
  var gi0 = actives[0];
469
588
  // ensure the group's window is loaded (with a small buffer below)
470
589
  setVisibleCount(function (prev) {
@@ -502,7 +621,19 @@ window.__ModuleLoader__.load({
502
621
  ? window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW
503
622
  : (viewport ? viewport.left + 8 : 8);
504
623
  } else {
505
- panelLeft = dockRight ? window.innerWidth + 24 : -(panelW + 48);
624
+ // collapsed position:
625
+ // left dock -> slide just past the left sidebar edge (stop there)
626
+ // right dock -> boundary is the right sidebar when it is open,
627
+ // otherwise the screen's right edge
628
+ if (dockRight) {
629
+ // with the right sidebar open, slide INTO the sidebar area (covered
630
+ // by the cover wall); otherwise fly off the screen's right edge
631
+ panelLeft = (viewport && viewport.right > 80)
632
+ ? (window.innerWidth - viewport.right)
633
+ : window.innerWidth + 24;
634
+ } else {
635
+ panelLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
636
+ }
506
637
  }
507
638
  var vMaxH = viewport ? Math.max(200, viewport.height - 28) : "72vh";
508
639
  var baseTopPx = (viewport ? viewport.top + 14 : window.innerHeight * 0.12) + panelY;
@@ -599,8 +730,26 @@ window.__ModuleLoader__.load({
599
730
  };
600
731
 
601
732
  // ---- 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";
733
+ // panel: opening slides in with a slow fade; closing slides quickly to
734
+ // the dock edge, clipped by the sidebar line (looks covered, not
735
+ // dissolving) and only fades at the very end. No box-shadow: a shadow
736
+ // would get cut in half by the clip-path, so the panel is flat.
737
+ var panelTransition = open
738
+ ? "left 0.6s " + EASE + ", clip-path 0.6s " + EASE + ", opacity 0.45s ease"
739
+ : "left 0.28s " + EASE + ", clip-path 0.28s " + EASE + ", opacity 0.14s ease 0.26s";
740
+
741
+ // clip the panel at the dock edge while collapsed, so sliding away looks
742
+ // like being covered by the sidebar (the sidebar stays untouched):
743
+ // left dock -> clipped from the left up to the sidebar line
744
+ // right dock -> clipped from the right at the screen/sidebar line
745
+ var panelClip = "inset(0 0 0 0px)";
746
+ if (!open) {
747
+ if (dockRight) {
748
+ panelClip = "inset(0 " + panelW + "px 0 0)";
749
+ } else {
750
+ panelClip = "inset(0 0 0 " + (panelW + 8) + "px)";
751
+ }
752
+ }
604
753
  var panelOpacity = open ? (hovered ? 0.95 : 0.45) : 0;
605
754
 
606
755
  var panelEl = react_jsx_runtime.jsx("div", {
@@ -621,6 +770,8 @@ window.__ModuleLoader__.load({
621
770
  overflow: "hidden",
622
771
  color: C.text,
623
772
  opacity: panelOpacity,
773
+ clipPath: panelClip,
774
+ boxShadow: "none",
624
775
  transition: panelTransition,
625
776
  pointerEvents: "auto"
626
777
  },
@@ -709,12 +860,12 @@ window.__ModuleLoader__.load({
709
860
  style: {
710
861
  overflowY: "auto",
711
862
  padding: "6px 8px",
712
- maxHeight: "60vh",
713
863
  flex: "1 1 auto",
714
864
  minHeight: 0,
715
865
  direction: dockRight ? "ltr" : "rtl"
716
866
  },
717
867
  onScroll: onListScroll,
868
+ onWheel: onListWheel,
718
869
  children: react_jsx_runtime.jsx("div", {
719
870
  style: { direction: "ltr" },
720
871
  children: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
@@ -867,6 +1018,50 @@ window.__ModuleLoader__.load({
867
1018
  return out;
868
1019
  }
869
1020
 
1021
+ // a group's header: clicking the time jumps to that turn's start.
1022
+ // Implemented as a function (not an inline closure in a loop) so each
1023
+ // header captures its own (g, gi) — the var-in-loop closure bug would
1024
+ // otherwise make every header jump to the last group.
1025
+ function renderGroupHeader(g, gi, jump, C) {
1026
+ var jumpToTurn = function (e) {
1027
+ e.stopPropagation();
1028
+ if (!g.userKey) return;
1029
+ var r0 = findRowStrict(g.userKey);
1030
+ if (r0) jump(g.userKey);
1031
+ else if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
1032
+ };
1033
+ return react_jsx_runtime.jsx("div", {
1034
+ style: { padding: "1px 4px 2px", height: 18, display: "flex", alignItems: "center", minWidth: 0 },
1035
+ children: react_jsx_runtime.jsx("span", {
1036
+ onClick: jumpToTurn,
1037
+ title: g.userKey ? "跳转到该回合开头" : "",
1038
+ onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
1039
+ onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
1040
+ style: {
1041
+ fontSize: 11,
1042
+ color: C.muted,
1043
+ cursor: "pointer",
1044
+ padding: "1px 5px",
1045
+ borderRadius: 4,
1046
+ transition: "background 0.15s ease",
1047
+ display: "inline-flex",
1048
+ alignItems: "center",
1049
+ gap: 5,
1050
+ minWidth: 0,
1051
+ maxWidth: "100%",
1052
+ overflow: "hidden"
1053
+ },
1054
+ children: [
1055
+ react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
1056
+ g.userText ? react_jsx_runtime.jsx("span", {
1057
+ style: { fontWeight: 400, opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
1058
+ children: g.userText
1059
+ }) : null
1060
+ ]
1061
+ })
1062
+ }, "g-h-" + gi);
1063
+ }
1064
+
870
1065
  // render each conversation turn as its own block: solid divider + time header
871
1066
  // offset = global group index of the first rendered group (stable React keys)
872
1067
  // activeIdx = array of groups being read (full opacity); others are dimmed
@@ -884,19 +1079,12 @@ window.__ModuleLoader__.load({
884
1079
  items.push(react_jsx_runtime.jsx("div", {
885
1080
  style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
886
1081
  }, "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
- }
1082
+ items.push(renderGroupHeader(g, gi, jump, C));
895
1083
  items.push(renderNodes(trees[i], 0, jump, C, "g" + gi));
896
1084
  var dim = !activeSet[gi];
897
1085
  out.push(react_jsx_runtime.jsx("div", {
898
1086
  "data-group-idx": gi,
899
- style: { opacity: dim ? 0.55 : 1, transition: "opacity 0.3s ease" },
1087
+ style: { opacity: dim ? 0.6 : 1, transition: "opacity 0.3s ease" },
900
1088
  children: items
901
1089
  }, "g-" + gi));
902
1090
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
  }