dsh-quick-toc 0.4.0 → 0.5.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
@@ -21,6 +21,57 @@ window.__ModuleLoader__.load({
21
21
  var Z_BASE = 500;
22
22
  var EASE = "cubic-bezier(0.22, 0.9, 0.3, 1)"; // smooth non-linear slide
23
23
 
24
+ // ---------- UI strings ----------
25
+ // Every string the panel renders goes through T(). 0.5.0 ships the Chinese
26
+ // column only — the visible text is byte-identical to 0.4.1 — so 0.6.0 can
27
+ // add English (or feed the same keys into the host locale table) without
28
+ // hunting for hard-coded literals a second time.
29
+ var DICTS = {
30
+ zh: {
31
+ "panel.title": "对话大纲",
32
+ "error.panel": "dsh-quick-toc 面板出错: ",
33
+ "handle.dragY": "按住拖动调整位置",
34
+ "handle.dockLeft": "移到左侧",
35
+ "handle.dockRight": "移到右侧",
36
+ "handle.collapse": "收起",
37
+ "handle.expand": "展开大纲",
38
+ "levels.tip": "标题层级筛选",
39
+ "levels.show": "显示 H",
40
+ "levels.hide": "隐藏 H",
41
+ "search.open": "搜索标题",
42
+ "search.word": "搜索",
43
+ "search.tail": ",回车定位…",
44
+ "search.scope.title": "标题",
45
+ "search.scope.full": "全文",
46
+ "search.scope.tipTitle": "当前:仅搜索标题。点击切换为全文搜索",
47
+ "search.scope.tipFull": "当前:全文搜索。点击切换为仅标题",
48
+ "search.fuzzy": "模糊",
49
+ "search.fuzzy.tipOn": "模糊匹配已开启:允许关键字中间夹少量其他文字,命中更多",
50
+ "search.fuzzy.tipOff": "模糊匹配已关闭:只匹配连续的文字。点击开启",
51
+ "search.empty": "没有匹配",
52
+ "search.hintMore": "向上滚动可加载更早的消息",
53
+ "search.hintOldest": "已经是最早的消息",
54
+ "hint.bottom": "已经到底了",
55
+ "turn.jumpReply": "跳转到该回合的模型回答开头",
56
+ "turn.jumpTurn": "跳转到该回合开头",
57
+ "turn.loading": "正在加载这个回合…",
58
+ "turn.unloaded": "未加载",
59
+ "turn.loadTip": "点击加载这个回合并跳转过去",
60
+ "preview.truncated": "预览由宿主提供,可能被截断",
61
+ "resize.w": "拖拽调整宽度",
62
+ "resize.h": "拖拽调整高度",
63
+ "resize.wh": "拖拽同时调整宽高"
64
+ }
65
+ };
66
+ // The dictionary the current render reads. Kept as a module-level binding so
67
+ // the module-scope render helpers (renderItem/renderResultRow/...) can localise
68
+ // without threading a `t` argument through every call.
69
+ var activeDict = DICTS.zh;
70
+ function T(key) {
71
+ var s = activeDict[key];
72
+ return s === undefined ? key : s;
73
+ }
74
+
24
75
  // ---------- markdown helpers ----------
25
76
  function extractReplyText(node) {
26
77
  var blocks = node && node.data && node.data.blocks;
@@ -78,6 +129,153 @@ window.__ModuleLoader__.load({
78
129
  return first;
79
130
  }
80
131
 
132
+ // ---------- normalized matching (fold, plus an optional fuzzy pass) ----------
133
+ // Folding ignores differences that never change meaning: letter case,
134
+ // full-width vs half-width forms (AB12,), and runs of whitespace. `map`
135
+ // keeps every folded character's ORIGINAL index, so a match found in folded
136
+ // space still highlights the untouched DOM text. Folding is ALWAYS on — it is
137
+ // correctness, not fuzziness: pasting "全角" text must find "全角" content.
138
+ function foldWithMap(text) {
139
+ var src = String(text === undefined || text === null ? "" : text);
140
+ var out = "";
141
+ var map = [];
142
+ var prevSpace = false;
143
+ for (var i = 0; i < src.length; i++) {
144
+ var ch = src.charAt(i);
145
+ var code = src.charCodeAt(i);
146
+ if (code >= 0xFF01 && code <= 0xFF5E) ch = String.fromCharCode(code - 0xFEE0); // full-width ASCII
147
+ else if (code === 0x3000) ch = " "; // ideographic space
148
+ var lower = ch.toLowerCase();
149
+ if (/\s/.test(lower)) {
150
+ if (prevSpace) continue; // collapse a whitespace run to one space
151
+ prevSpace = true;
152
+ out += " ";
153
+ map.push(i);
154
+ continue;
155
+ }
156
+ prevSpace = false;
157
+ out += lower;
158
+ map.push(i);
159
+ }
160
+ map.push(src.length); // end sentinel: source index just past the last character
161
+ return { text: out, map: map };
162
+ }
163
+
164
+ function foldQuery(q) {
165
+ return foldWithMap(q).text.trim();
166
+ }
167
+
168
+ // Contiguous occurrences of the folded query — the panel's original
169
+ // behaviour (case-insensitive, non-overlapping), now fold-aware.
170
+ function exactMatches(folded, fq) {
171
+ var hits = [];
172
+ if (!fq) return hits;
173
+ var at = 0;
174
+ while ((at = folded.text.indexOf(fq, at)) !== -1) {
175
+ hits.push({ ranges: [[folded.map[at], folded.map[at + fq.length]]] });
176
+ at += fq.length;
177
+ }
178
+ return hits;
179
+ }
180
+
181
+ // How far apart the matched characters of one fuzzy hit may sit. Without a
182
+ // bound, "提交" would match any text that happens to contain 提…交 somewhere,
183
+ // which buries the real hits; the span keeps fuzzy useful for "关键字中间夹
184
+ // 了别的字" without turning the list into noise.
185
+ function fuzzySpan(fq) {
186
+ return Math.max(12, fq.length * 2 + 8);
187
+ }
188
+
189
+ // Subsequence occurrences (fuzzy mode): the query's characters must appear in
190
+ // order, each hit is the shortest window found from the current position, and
191
+ // hits never overlap. Adjacent matched characters are merged into one range so
192
+ // a contiguous hit still highlights as a whole word.
193
+ function fuzzyMatches(folded, fq) {
194
+ var hits = [];
195
+ if (!fq) return hits;
196
+ var span = fuzzySpan(fq);
197
+ var from = 0;
198
+ for (;;) {
199
+ var start = -1;
200
+ var at = from;
201
+ var ranges = [];
202
+ var ok = true;
203
+ for (var i = 0; i < fq.length; i++) {
204
+ var hit = folded.text.indexOf(fq.charAt(i), at);
205
+ if (hit === -1 || (start !== -1 && hit - start > span)) { ok = false; break; }
206
+ if (start === -1) start = hit;
207
+ var raw = [folded.map[hit], folded.map[hit + 1]];
208
+ var last = ranges.length > 0 ? ranges[ranges.length - 1] : null;
209
+ if (last !== null && last[1] === raw[0] && folded.text.charAt(hit - 1) !== " ") last[1] = raw[1];
210
+ else ranges.push(raw);
211
+ at = hit + 1;
212
+ }
213
+ if (!ok) break;
214
+ hits.push({ ranges: ranges });
215
+ from = at;
216
+ if (from >= folded.text.length) break;
217
+ }
218
+ return hits;
219
+ }
220
+
221
+ function findMatches(folded, fq, fuzzy) {
222
+ if (!fq) return [];
223
+ return fuzzy ? fuzzyMatches(folded, fq) : exactMatches(folded, fq);
224
+ }
225
+
226
+ // Number of hits of the folded query in `text` (used for ×N and n/N counts).
227
+ function countOccurrences(text, q, fuzzy) {
228
+ var fq = q && q.folded !== undefined ? q.folded : foldQuery(q);
229
+ if (!text || !fq) return 0;
230
+ return findMatches(foldWithMap(text), fq, !!fuzzy).length;
231
+ }
232
+
233
+ // ---------- section bodies: subtitle + hover preview ----------
234
+ // Body lines of one heading: everything after it up to the next heading in
235
+ // the same message.
236
+ function sectionPreview(lines, from, to, max) {
237
+ if (!lines || from >= to) return "";
238
+ var out = "";
239
+ var fence = null;
240
+ for (var i = from; i < to; i++) {
241
+ var line = lines[i];
242
+ var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
243
+ if (fence !== null) {
244
+ if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
245
+ continue;
246
+ }
247
+ if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
248
+ var t = line.trim();
249
+ if (t === "") continue;
250
+ out += (out === "" ? "" : " ") + t;
251
+ if (out.length >= max) return out.slice(0, max).trim() + "…";
252
+ }
253
+ return out;
254
+ }
255
+
256
+ // First readable sentence of a section — the outline row's subtitle. Skips
257
+ // blank lines, code fences, table rules and bare bullet/emphasis markers so a
258
+ // "###" block that opens with a table still gets a meaningful line.
259
+ function firstSentence(lines, from, to, max) {
260
+ if (!lines || from >= to) return "";
261
+ var fence = null;
262
+ for (var i = from; i < to; i++) {
263
+ var line = lines[i];
264
+ var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
265
+ if (fence !== null) {
266
+ if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
267
+ continue;
268
+ }
269
+ if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
270
+ var t = line.trim().replace(/^[>+\-*]\s+/, "").replace(/^\d+[.)]\s+/, "");
271
+ if (t === "" || /^[|\-=*_\s]+$/.test(t)) continue;
272
+ t = cleanTitle(t);
273
+ if (t === "") continue;
274
+ return t.length > max ? t.slice(0, max).trim() + "…" : t;
275
+ }
276
+ return "";
277
+ }
278
+
81
279
  // Headings are collected line by line so FENCED CODE BLOCKS can be skipped:
82
280
  // a "```" block that documents markdown (or shows a shell comment like
83
281
  // "# install") contains lines that look like headings but are code — they
@@ -99,18 +297,18 @@ window.__ModuleLoader__.load({
99
297
  }
100
298
  if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
101
299
  var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
102
- if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
300
+ if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()), line: i });
103
301
  }
104
302
  return items;
105
303
  }
106
304
 
107
- function buildTree(headings) {
305
+ function buildTree(headings, time) {
108
306
  var root = { level: 0, children: [] };
109
307
  var stack = [root];
110
308
  for (var i = 0; i < headings.length; i++) {
111
309
  var h = headings[i];
112
310
  while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop();
113
- var node = { level: h.level, title: h.title, key: h.key, idx: h.idx, children: [] };
311
+ var node = { level: h.level, title: h.title, key: h.key, idx: h.idx, sub: h.sub, preview: h.preview, time: time, children: [] };
114
312
  stack[stack.length - 1].children.push(node);
115
313
  stack.push(node);
116
314
  }
@@ -122,6 +320,7 @@ window.__ModuleLoader__.load({
122
320
  // so a streaming update only re-joins/re-parses the node that actually
123
321
  // changed instead of every message in the history.
124
322
  var EMPTY_HEADINGS = [];
323
+ var EMPTY_LINES = [];
125
324
  var nodeInfoCache = new Map();
126
325
  function nodeInfo(key, node) {
127
326
  var isUser = node.kind === "user";
@@ -132,12 +331,24 @@ window.__ModuleLoader__.load({
132
331
  return cached;
133
332
  }
134
333
  var text = isUser ? extractUserText(node) : extractReplyText(node);
334
+ var lines = text ? text.split("\n") : EMPTY_LINES;
335
+ var headings = node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS;
336
+ // per-heading subtitle (first sentence) + hover preview (section opening) —
337
+ // computed here, with the parse, so streaming updates only pay for the node
338
+ // that actually changed. `line` marks where the heading sits in `lines`, and
339
+ // its section runs to the next heading of ANY level (or the message end).
340
+ for (var hi = 0; hi < headings.length; hi++) {
341
+ var toLine = hi + 1 < headings.length ? headings[hi + 1].line : lines.length;
342
+ headings[hi].sub = firstSentence(lines, headings[hi].line + 1, toLine, 44);
343
+ headings[hi].preview = sectionPreview(lines, headings[hi].line + 1, toLine, 260);
344
+ }
135
345
  var info = {
136
346
  node: node,
137
347
  blocks: blocks,
138
348
  user: isUser,
139
349
  text: text,
140
- headings: node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS
350
+ lines: lines,
351
+ headings: headings
141
352
  };
142
353
  nodeInfoCache.set(key, info);
143
354
  return info;
@@ -155,33 +366,44 @@ window.__ModuleLoader__.load({
155
366
  return h.key + "#" + h.idx;
156
367
  }
157
368
 
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) {
369
+ // Split `text` into runs around the hits of `q`, so the caller can render the
370
+ // hits distinctly. Returns [{ text, hit }, ...]. Runs on folded text, so a
371
+ // full-width or differently-cased match is highlighted where it really sits,
372
+ // and a fuzzy hit paints each matched character run.
373
+ function highlightParts(text, q, fuzzy) {
161
374
  var parts = [];
162
375
  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;
376
+ var fq = foldQuery(q);
377
+ if (!fq) return [{ text: text, hit: false }];
378
+ var hits = findMatches(foldWithMap(text), fq, !!fuzzy);
379
+ if (hits.length === 0) return [{ text: text, hit: false }];
380
+ var cursor = 0;
381
+ for (var i = 0; i < hits.length; i++) {
382
+ var ranges = hits[i].ranges;
383
+ for (var r = 0; r < ranges.length; r++) {
384
+ var from = ranges[r][0];
385
+ var to = ranges[r][1];
386
+ if (from < cursor || to <= from) continue;
387
+ if (from > cursor) parts.push({ text: text.slice(cursor, from), hit: false });
388
+ parts.push({ text: text.slice(from, to), hit: true });
389
+ cursor = to;
390
+ }
172
391
  }
173
- if (from < text.length) parts.push({ text: text.slice(from), hit: false });
392
+ if (cursor < text.length) parts.push({ text: text.slice(cursor), hit: false });
174
393
  return parts;
175
394
  }
176
395
 
177
396
  // A one-line window around the first hit of `q` in `text` (for search results).
178
- function snippetAround(text, q, span) {
397
+ function snippetAround(text, q, span, fuzzy) {
179
398
  if (!text) return "";
180
399
  var flat = text.replace(/\s+/g, " ").trim();
181
- var at = flat.toLowerCase().indexOf(q);
182
- if (at === -1) return previewText(flat, span * 2);
400
+ var fq = foldQuery(q);
401
+ var folded = fq ? foldWithMap(flat) : null;
402
+ var hits = folded ? findMatches(folded, fq, !!fuzzy) : [];
403
+ if (hits.length === 0) return previewText(flat, span * 2);
404
+ var at = folded.map[hits[0].ranges[0][0]];
183
405
  var start = Math.max(0, at - span);
184
- var end = Math.min(flat.length, at + q.length + span);
406
+ var end = Math.min(flat.length, at + span * 2);
185
407
  return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
186
408
  }
187
409
 
@@ -228,21 +450,6 @@ window.__ModuleLoader__.load({
228
450
  }
229
451
 
230
452
  // count every occurrence of q (case-insensitive) inside text
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;
236
- var lower = text.toLowerCase();
237
- var count = 0;
238
- var idx = 0;
239
- while ((idx = lower.indexOf(q, idx)) !== -1) {
240
- count++;
241
- idx += q.length;
242
- }
243
- return count;
244
- }
245
-
246
453
  // find the conversation's "load older messages" button (scoped to the
247
454
  // conversation scrollport so panel buttons are never matched)
248
455
  function findLoadOlderButton() {
@@ -320,10 +527,17 @@ window.__ModuleLoader__.load({
320
527
  highlightSpans = [];
321
528
  }
322
529
 
323
- // wrap every occurrence of q (case-insensitive) inside row's text nodes;
324
- // the occurrence at `currentOcc` gets a distinct "current" highlight
325
- function highlightRow(row, q, currentOcc) {
530
+ // wrap every hit of q inside row's text nodes; the hit at `currentOcc` gets a
531
+ // distinct "current" highlight. Matching runs on FOLDED text (case/full-width/
532
+ // whitespace insensitive) and, in fuzzy mode, on subsequences — a hit is then a
533
+ // set of character ranges, so one fuzzy hit can paint several spans.
534
+ // NOTE: these fills sit ON TOP OF conversation text, so they stay deliberately
535
+ // stronger than the panel's UI tints (C.chip / C.groupTint); only the current
536
+ // hit's outline reuses the shared accent value.
537
+ function highlightRow(row, q, currentOcc, fuzzy) {
326
538
  if (!row || !q) return;
539
+ var fq = foldQuery(q);
540
+ if (!fq) return;
327
541
  var walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null);
328
542
  var textNodes = [];
329
543
  while (walker.nextNode()) textNodes.push(walker.currentNode);
@@ -332,38 +546,41 @@ window.__ModuleLoader__.load({
332
546
  var node = textNodes[i];
333
547
  var text = node.nodeValue;
334
548
  if (!text) continue;
335
- var lower = text.toLowerCase();
336
- if (lower.indexOf(q) < 0) continue;
549
+ var hits = findMatches(foldWithMap(text), fq, !!fuzzy);
550
+ if (hits.length === 0) continue;
337
551
  var frag = document.createDocumentFragment();
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
552
+ // EVERY hit inside this text node must be wrapped, in order: the caller
553
+ // addresses hits by their global index (`occ`), which is counted over the
554
+ // message text. Wrapping only the first one per text node made a second
555
+ // hit on the same line un-markable, so stepping to it found no
342
556
  // `.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);
557
+ var cursor = 0;
558
+ for (var h = 0; h < hits.length; h++) {
559
+ var isCurrent = occ === currentOcc;
361
560
  occ++;
362
- from = idx + q.length;
363
- idx = lower.indexOf(q, from);
561
+ var ranges = hits[h].ranges;
562
+ for (var r = 0; r < ranges.length; r++) {
563
+ var from = ranges[r][0];
564
+ var to = ranges[r][1];
565
+ if (from < cursor || to <= from) continue;
566
+ if (from > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, from)));
567
+ var mark = document.createElement("span");
568
+ if (isCurrent) {
569
+ mark.className = "dqt-current";
570
+ mark.style.background = "rgba(79,140,255,0.55)";
571
+ mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
572
+ } else {
573
+ mark.style.background = "rgba(79,140,255,0.32)";
574
+ }
575
+ mark.style.borderRadius = "2px";
576
+ mark.style.color = "inherit";
577
+ mark.textContent = text.slice(from, to);
578
+ frag.appendChild(mark);
579
+ highlightSpans.push(mark);
580
+ cursor = to;
581
+ }
364
582
  }
365
- var after = text.slice(from);
366
- if (after) frag.appendChild(document.createTextNode(after));
583
+ if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor)));
367
584
  node.parentNode.replaceChild(frag, node);
368
585
  }
369
586
  }
@@ -376,7 +593,14 @@ window.__ModuleLoader__.load({
376
593
  muted: "var(--dsw-alias-label-secondary, #9aa0ab)",
377
594
  accent: "var(--dsw-alias-brand-primary, #4f8cff)",
378
595
  hover: "var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))",
379
- chip: "rgba(79, 140, 255, 0.18)"
596
+ // ONE source for every "this control is on / this button is open / this match
597
+ // is current" tint, so the panel cannot drift into five near-identical blues.
598
+ chip: "rgba(79, 140, 255, 0.18)",
599
+ // the followed group is a LARGE surface: same hue, lighter fill, with a
600
+ // closing outline + accent bar so the block reads as a finished shape.
601
+ groupTint: "rgba(79, 140, 255, 0.10)",
602
+ groupEdge: "rgba(79, 140, 255, 0.85)",
603
+ groupEdgeSoft: "rgba(79, 140, 255, 0.35)"
380
604
  };
381
605
 
382
606
  // ---- theme scheme detection (for the panel's 3D inner shadow) ----
@@ -444,7 +668,7 @@ window.__ModuleLoader__.load({
444
668
  maxWidth: 340,
445
669
  wordBreak: "break-all"
446
670
  },
447
- children: "dsh-quick-toc 面板出错: " + msg
671
+ children: T("error.panel") + msg
448
672
  });
449
673
  }
450
674
  return this.props.children;
@@ -466,6 +690,68 @@ window.__ModuleLoader__.load({
466
690
  var order = useChat(function (s) { return s.order; });
467
691
  var nodes = useChat(function (s) { return s.nodes; });
468
692
 
693
+ // Whole-log turn index: the host's `turnOutline` projection
694
+ // (@deepseek-ai/dsh-session-turn-outline) names EVERY turn of the session —
695
+ // including turns the paged event window has not loaded yet — with a bounded
696
+ // prompt/response preview and the `turn/start` seq that pages history to it.
697
+ // On a host without that projection the value is undefined and the panel
698
+ // behaves exactly like 0.4.1 (loaded turns only).
699
+ var useProjection = props.useProjection;
700
+ var rawOutline = useProjection ? useProjection("turnOutline") : null;
701
+ // { sessions() } bridge to the host session face, injected by apply().
702
+ // It is a LOOKUP (ctx.get), not a cached reference: the service may be
703
+ // registered after this package loads, and an unloaded provider must not
704
+ // leave a stale handle behind.
705
+ var tocHost = props.tocHost;
706
+ var sessionId = props.sessionId;
707
+ var hostSessions = function () {
708
+ if (!tocHost || typeof tocHost.sessions !== "function") return null;
709
+ try {
710
+ return tocHost.sessions();
711
+ } catch (e) {
712
+ return null;
713
+ }
714
+ };
715
+ // Which stage of the bridge is actually ready — reported once at mount so a
716
+ // missing jump loader can be diagnosed from the console instead of guessed.
717
+ var jumpLoaderState = function () {
718
+ var sessions = hostSessions();
719
+ if (!sessions) return "no-sessions-service";
720
+ if (typeof sessions.binding !== "function") return "no-binding-api";
721
+ var binding = null;
722
+ try {
723
+ binding = sessions.binding(sessionId);
724
+ } catch (e) {
725
+ return "binding-threw";
726
+ }
727
+ var face = binding && binding.session;
728
+ if (!face) return "no-binding-for-session";
729
+ return typeof face.loadThrough === "function" ? "ready" : "no-loadThrough";
730
+ };
731
+
732
+ // Structural narrowing of that projection (its value crosses the wire):
733
+ // `turn` and `seq` are load-bearing — an entry without them cannot be
734
+ // shown or jumped to — while the previews are decorative and degrade to "".
735
+ var outlineTurns = react.useMemo(function () {
736
+ var list = Array.isArray(rawOutline) ? rawOutline
737
+ : (rawOutline && Array.isArray(rawOutline.turns) ? rawOutline.turns : null);
738
+ if (!list) return null; // projection unavailable: loaded turns only
739
+ var out = [];
740
+ for (var i = 0; i < list.length; i++) {
741
+ var e = list[i];
742
+ if (!e || typeof e !== "object") continue;
743
+ if (typeof e.turn !== "number" || !isFinite(e.turn) || e.turn < 0) continue;
744
+ if (typeof e.seq !== "number" || !isFinite(e.seq) || e.seq < 0) continue;
745
+ out.push({
746
+ turn: e.turn,
747
+ seq: e.seq,
748
+ prompt: typeof e.prompt === "string" ? e.prompt : "",
749
+ response: typeof e.response === "string" ? e.response : ""
750
+ });
751
+ }
752
+ return out;
753
+ }, [rawOutline]);
754
+
469
755
  // ---- hooks (ALL before any conditional return) ----
470
756
  // collapsed by default; user expands via the edge handle (default dock: left)
471
757
  var _s1 = react.useState(false);
@@ -617,27 +903,43 @@ window.__ModuleLoader__.load({
617
903
  try { localStorage.setItem(LEVELS_KEY, JSON.stringify(next)); } catch (e) {}
618
904
  };
619
905
 
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) {}
906
+ // ---- heading-level picker popup (transient): opens from the round button
907
+ // next to the search button; closes on an outside pointerdown. Closing
908
+ // plays a short shrink-back animation before unmounting. ----
909
+ var _sLo = react.useState(false);
910
+ var levelsOpen = _sLo[0];
911
+ var setLevelsOpen = _sLo[1];
912
+ var _sLc = react.useState(false);
913
+ var levelsClosing = _sLc[0];
914
+ var setLevelsClosing = _sLc[1];
915
+ var closeLevels = function () {
916
+ if (!levelsOpen || levelsClosing) return;
917
+ setLevelsClosing(true);
918
+ // unmount when the exit animation actually finishes (fallback timer in
919
+ // case animationend never fires, e.g. display:none ancestors)
920
+ var popped = document.querySelector(".dqt-levels-pop");
921
+ var done = false;
922
+ var finish = function () {
923
+ if (done) return;
924
+ done = true;
925
+ if (popped && popped.removeEventListener) popped.removeEventListener("animationend", finish);
926
+ setLevelsOpen(false);
927
+ setLevelsClosing(false);
928
+ };
929
+ if (popped && popped.addEventListener) popped.addEventListener("animationend", finish);
930
+ setTimeout(finish, 220);
633
931
  };
932
+ react.useEffect(function () {
933
+ if (!levelsOpen) return;
934
+ var close = function (e) {
935
+ var t = e.target;
936
+ if (t && t.closest && (t.closest(".dqt-levels-pop") || t.closest(".dqt-levels-btn"))) return;
937
+ closeLevels();
938
+ };
939
+ document.addEventListener("pointerdown", close);
940
+ return function () { document.removeEventListener("pointerdown", close); };
941
+ }, [levelsOpen, levelsClosing]);
634
942
 
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
943
 
642
944
  // ---- pagination: show the latest N groups; scrolling to the top loads older ----
643
945
  var PAGE_SIZE = 6;
@@ -647,6 +949,79 @@ window.__ModuleLoader__.load({
647
949
  var listRef = react.useRef(null);
648
950
  var didInitScroll = react.useRef(false);
649
951
  var outlineTouchRef = react.useRef(0); // last time the user touched the outline
952
+ var lastScrollTopRef = react.useRef(0);
953
+ var lastHostClickRef = react.useRef(0); // throttle for the "load earlier" click
954
+
955
+ // ---- "scroll up to load earlier messages" hint (search mode) -----------
956
+ // Armed by a search when older history is still reachable; dismissed the
957
+ // moment the reader scrolls up (which is when the older page starts loading,
958
+ // so the hint has done its job). "" | "more" | "oldest"
959
+ var _sHint = react.useState("");
960
+ var hint = _sHint[0];
961
+ var setHint = _sHint[1];
962
+
963
+ // ---- transient edge banner (outline mode) ------------------------------
964
+ // Reaching either end of the outline flashes a short bar over the bottom of
965
+ // the list: fade in, hold a few seconds, fade out. Deliberately NOT the same
966
+ // element as the search hint above (which stays until the reader acts).
967
+ var _sToast = react.useState(null);
968
+ var toast = _sToast[0];
969
+ var setToast = _sToast[1];
970
+ var toastTimersRef = react.useRef({ hold: null, out: null });
971
+ var showBanner = function (text) {
972
+ if (toastTimersRef.current.hold) clearTimeout(toastTimersRef.current.hold);
973
+ if (toastTimersRef.current.out) clearTimeout(toastTimersRef.current.out);
974
+ setToast({ text: text, closing: false });
975
+ toastTimersRef.current.hold = setTimeout(function () {
976
+ setToast(function (cur) { return cur === null ? null : { text: cur.text, closing: true }; });
977
+ toastTimersRef.current.out = setTimeout(function () { setToast(null); }, 460);
978
+ }, 2600);
979
+ };
980
+
981
+ // ---- hover preview card -------------------------------------------------
982
+ // Hovering an outline/result row opens a card with the section's opening
983
+ // lines; it lives in the body portal so the scrolling list cannot clip it.
984
+ var _sHov = react.useState(null);
985
+ var hoverCard = _sHov[0];
986
+ var setHoverCard = _sHov[1];
987
+ var _sHovOut = react.useState(false);
988
+ var hoverClosing = _sHovOut[0];
989
+ var setHoverClosing = _sHovOut[1];
990
+ var hoverTimerRef = react.useRef(null);
991
+ var hoverCloseTimerRef = react.useRef(null);
992
+ var hoverStart = function (info, el) {
993
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
994
+ hoverTimerRef.current = setTimeout(function () {
995
+ var r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null;
996
+ if (!r || r.width <= 0) return;
997
+ if (hoverCloseTimerRef.current) {
998
+ clearTimeout(hoverCloseTimerRef.current);
999
+ hoverCloseTimerRef.current = null;
1000
+ }
1001
+ setHoverClosing(false);
1002
+ setHoverCard({
1003
+ title: info.title,
1004
+ sub: info.sub,
1005
+ preview: info.preview,
1006
+ meta: info.meta,
1007
+ ghost: info.ghost,
1008
+ top: r.top,
1009
+ left: r.left,
1010
+ right: r.right
1011
+ });
1012
+ }, 260);
1013
+ };
1014
+ // leaving fades the card out instead of dropping it on the next frame
1015
+ var hoverEnd = function () {
1016
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
1017
+ if (hoverCloseTimerRef.current) clearTimeout(hoverCloseTimerRef.current);
1018
+ setHoverClosing(true);
1019
+ hoverCloseTimerRef.current = setTimeout(function () {
1020
+ hoverCloseTimerRef.current = null;
1021
+ setHoverCard(null);
1022
+ setHoverClosing(false);
1023
+ }, 240);
1024
+ };
650
1025
 
651
1026
  // ---- the outline belongs to the CHAT view only: when the center column
652
1027
  // switches to another view (trajectory / context / plugin views), fade the
@@ -718,6 +1093,21 @@ window.__ModuleLoader__.load({
718
1093
  if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
719
1094
  scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
720
1095
  };
1096
+ // ---- fuzzy matching: an independent switch beside the scope pill --------
1097
+ // Off (default) = the folded CONTIGUOUS match. On = subsequence matching, so
1098
+ // keywords tolerate text wedged in between ("模糊匹配" also finds "模糊的匹配").
1099
+ var FUZZY_KEY = "dsh-quick-toc.fuzzy.v1";
1100
+ var _sFz = react.useState(function () {
1101
+ try { return localStorage.getItem(FUZZY_KEY) === "1"; } catch (e) { return false; }
1102
+ });
1103
+ var fuzzy = _sFz[0];
1104
+ var setFuzzy = _sFz[1];
1105
+ var toggleFuzzy = function () {
1106
+ var next = !fuzzy;
1107
+ setFuzzy(next);
1108
+ setMatchIdx(0);
1109
+ try { localStorage.setItem(FUZZY_KEY, next ? "1" : "0"); } catch (e) {}
1110
+ };
721
1111
  var openSearch = function () {
722
1112
  setSearchOpen(true);
723
1113
  setSearchAnim("enter");
@@ -773,9 +1163,9 @@ window.__ModuleLoader__.load({
773
1163
  }
774
1164
  return false;
775
1165
  };
776
- if (grow()) return;
777
- var btn = findLoadOlderButton();
778
- if (btn && !btn.disabled) {
1166
+ var viaHost = function () {
1167
+ var btn = findLoadOlderButton();
1168
+ if (!btn || btn.disabled) return false;
779
1169
  btn.click();
780
1170
  // once the conversation loads more, expand the outline window too
781
1171
  setTimeout(function () {
@@ -788,7 +1178,34 @@ window.__ModuleLoader__.load({
788
1178
  });
789
1179
  }
790
1180
  }, 1200);
791
- }
1181
+ return true;
1182
+ };
1183
+ var viaHostThrottled = function () {
1184
+ var now = Date.now();
1185
+ if (now - lastHostClickRef.current < 900) return false;
1186
+ if (!findLoadOlderButton()) return false;
1187
+ lastHostClickRef.current = now;
1188
+ return viaHost();
1189
+ };
1190
+ // In SEARCH mode the list shows matches, not the outline: paging the
1191
+ // outline's own window would change nothing the reader can see, whereas
1192
+ // loading the conversation's older messages extends what the search can
1193
+ // cover at all (the whole-log index only carries bounded previews). So the
1194
+ // host's own "load earlier" comes first while a query is active.
1195
+ if (query.trim() !== "") return viaHost() || grow();
1196
+ // Outline mode keeps the ORIGINAL contract: scrolling the outline up also
1197
+ // pulls the conversation's older messages in (that is what makes the outline
1198
+ // a substitute for scrolling the transcript), not merely more index entries.
1199
+ // The host click is throttled so one continuous scroll cannot hammer the
1200
+ // pager; grow() still runs first so the index never lags behind the window.
1201
+ var grew = grow();
1202
+ return viaHostThrottled() || grew;
1203
+ };
1204
+
1205
+ // older history still reachable? the outline can always page its own window
1206
+ // further, and beyond that the conversation's own "load older" button decides
1207
+ var canLoadOlder = function () {
1208
+ return visibleCount < groups.length || !!findLoadOlderButton();
792
1209
  };
793
1210
 
794
1211
  // wheel up (toward older) loads more when the list is at its top or has
@@ -796,17 +1213,52 @@ window.__ModuleLoader__.load({
796
1213
  // always refreshes older turns, even without a visible scrollbar
797
1214
  var onListWheel = function (e) {
798
1215
  outlineTouchRef.current = Date.now();
799
- if (e.deltaY >= 0) return;
800
1216
  var el = listRef.current;
801
1217
  if (!el) return;
802
- if (el.scrollTop <= 1) loadOlderOutline(el);
1218
+ hoverEnd();
1219
+ if (e.deltaY >= 0) {
1220
+ // still scrolling down at the very end of the outline: say so. Outline
1221
+ // mode only — the search list has its own persistent hint line.
1222
+ var max = el.scrollHeight - el.clientHeight;
1223
+ if (max > 4 && el.scrollTop >= max - 4 && query.trim() === "") showBanner(T("hint.bottom"));
1224
+ return;
1225
+ }
1226
+ if (hint === "more") setHint(""); // scrolling up is what the hint asked for
1227
+ if (el.scrollTop <= 1) {
1228
+ var progressed = loadOlderOutline(el);
1229
+ if (!progressed) {
1230
+ if (query.trim() !== "") {
1231
+ if (hint !== "oldest") setHint("oldest");
1232
+ } else {
1233
+ showBanner(T("search.hintOldest"));
1234
+ }
1235
+ }
1236
+ }
803
1237
  };
804
1238
 
805
1239
  // scroll to the top edge also loads older groups (keeps the visual position)
806
1240
  var onListScroll = function (e) {
807
1241
  outlineTouchRef.current = Date.now();
808
1242
  var el = e.currentTarget;
809
- if (el.scrollTop <= 24) loadOlderOutline(el);
1243
+ var upward = el.scrollTop < lastScrollTopRef.current - 2;
1244
+ if (upward) {
1245
+ if (hint === "more") setHint("");
1246
+ if (hoverCard) hoverEnd();
1247
+ }
1248
+ lastScrollTopRef.current = el.scrollTop;
1249
+ if (el.scrollTop <= 24) {
1250
+ // Reaching the top is the ONLY place the exhausted case can be told
1251
+ // apart from "keep scrolling": after a search the list is parked at the
1252
+ // bottom, so the first wheel-up merely scrolls and never gets here.
1253
+ var progressed = loadOlderOutline(el);
1254
+ if (!progressed && upward) {
1255
+ if (query.trim() !== "") {
1256
+ if (hint !== "oldest") setHint("oldest");
1257
+ } else {
1258
+ showBanner(T("search.hintOldest"));
1259
+ }
1260
+ }
1261
+ }
810
1262
  };
811
1263
 
812
1264
  // ---- resize drags (right edge = width, bottom edge = height) ----
@@ -961,14 +1413,53 @@ window.__ModuleLoader__.load({
961
1413
  var info = nodeInfo(key, node);
962
1414
  current.msgs.push({ key: key, text: info.text });
963
1415
  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 });
1416
+ current.headings.push({
1417
+ level: info.headings[k].level,
1418
+ title: info.headings[k].title,
1419
+ key: key,
1420
+ idx: k,
1421
+ sub: info.headings[k].sub,
1422
+ preview: info.headings[k].preview
1423
+ });
965
1424
  }
966
1425
  }
967
1426
  pruneNodeInfo(seen);
968
1427
  // keep a turn when it has headings OR a time — turns without headings
969
1428
  // still get a standalone time entry in the outline (click to jump)
970
- return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
971
- }, [order, nodes]);
1429
+ var ready = result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
1430
+ if (outlineTurns === null) return ready;
1431
+ // ---- merge the whole-log turn index ---------------------------------
1432
+ // Loaded groups keep their order (a group whose node carries no turn
1433
+ // location stays where it was found); every turn the paged window has not
1434
+ // loaded yet becomes an "unloaded" entry in the gap where it belongs, so
1435
+ // the outline covers the whole session and heading-less turns stay
1436
+ // reachable by number. Preview text comes from the projection and is
1437
+ // bounded by it (host-side 50/120 character budgets).
1438
+ var merged = [];
1439
+ var li = 0;
1440
+ for (var ei = 0; ei < outlineTurns.length; ei++) {
1441
+ var entry = outlineTurns[ei];
1442
+ while (li < ready.length && (ready[li].turn === null || ready[li].turn < entry.turn)) {
1443
+ merged.push(ready[li]);
1444
+ li++;
1445
+ }
1446
+ if (li < ready.length && ready[li].turn === entry.turn) continue; // emitted above
1447
+ merged.push({
1448
+ turn: entry.turn,
1449
+ seq: entry.seq,
1450
+ ghost: true,
1451
+ time: "",
1452
+ userKey: "",
1453
+ userText: entry.prompt,
1454
+ userFull: "",
1455
+ msgs: [],
1456
+ headings: [],
1457
+ preview: entry.response
1458
+ });
1459
+ }
1460
+ while (li < ready.length) { merged.push(ready[li]); li++; }
1461
+ return merged;
1462
+ }, [order, nodes, outlineTurns]);
972
1463
  // first time content appears, scroll the list to the bottom (newest).
973
1464
  // NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
974
1465
  // during render, so reading `groups` before that `var` is assigned would
@@ -978,14 +1469,25 @@ window.__ModuleLoader__.load({
978
1469
  didInitScroll.current = true;
979
1470
  listRef.current.scrollTop = listRef.current.scrollHeight;
980
1471
  }, [groups.length]);
1472
+ // one-line diagnostic so the loaded build AND the host capabilities it can
1473
+ // actually reach are identifiable from the browser console
1474
+ var mountLogRef = react.useRef(false);
1475
+ react.useEffect(function () {
1476
+ if (mountLogRef.current) return;
1477
+ mountLogRef.current = true;
1478
+ console.log("[dsh-quick-toc] panel mounted · turnOutline=" +
1479
+ (outlineTurns ? outlineTurns.length + " turns" : "unavailable") +
1480
+ " · jumpLoader=" + jumpLoaderState());
1481
+ }, [outlineTurns]);
1482
+
981
1483
  var groupTrees = react.useMemo(function () {
982
1484
  return groups.map(function (g) {
983
- if (levels.length === 6) return buildTree(g.headings);
1485
+ if (levels.length === 6) return buildTree(g.headings, g.time);
984
1486
  var kept = [];
985
1487
  for (var i = 0; i < g.headings.length; i++) {
986
1488
  if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
987
1489
  }
988
- return buildTree(kept);
1490
+ return buildTree(kept, g.time);
989
1491
  });
990
1492
  }, [groups, levels, levelSet]);
991
1493
  // pagination slice: the latest `visibleCount` groups
@@ -993,18 +1495,28 @@ window.__ModuleLoader__.load({
993
1495
  var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
994
1496
 
995
1497
  // the group currently being read (the turn under the middle of the
996
- // CONVERSATION viewport) stays bright in the outline; others are dimmed
1498
+ // CONVERSATION viewport) stays bright in the outline; others are dimmed.
1499
+ // Every node key of every loaded group -> its group index: mapping ONLY the
1500
+ // heading-bearing messages meant a turn with no markdown headings had no
1501
+ // entry at all, so a jump that landed on it (the host's own turn rail, for
1502
+ // one) could never light up or follow in the outline.
997
1503
  var keyToGroup = react.useMemo(function () {
998
1504
  var m = {};
999
1505
  for (var i = 0; i < groups.length; i++) {
1000
- for (var j = 0; j < groups[i].headings.length; j++) {
1001
- m[groups[i].headings[j].key] = i;
1506
+ var g = groups[i];
1507
+ if (g.ghost) continue; // no loaded rows to match
1508
+ if (g.userKey) m[g.userKey] = i;
1509
+ for (var j = 0; j < g.headings.length; j++) {
1510
+ m[g.headings[j].key] = i;
1511
+ }
1512
+ for (var k = 0; k < g.msgs.length; k++) {
1513
+ m[g.msgs[k].key] = i;
1002
1514
  }
1003
1515
  }
1004
1516
  return m;
1005
1517
  }, [groups]);
1006
1518
 
1007
- // ancestor path of every heading occurrence (breadcrumb + result rows):
1519
+ // ancestor path of every heading occurrence (used by the search result rows):
1008
1520
  // id -> { path, title, level, gi }
1009
1521
  var headingPaths = react.useMemo(function () {
1010
1522
  var byId = {};
@@ -1029,8 +1541,9 @@ window.__ModuleLoader__.load({
1029
1541
  // search result rows: one row per matched heading/message (with an
1030
1542
  // occurrence count), carrying its heading path, turn time and a snippet.
1031
1543
  var resultRows = react.useMemo(function () {
1032
- var q = query.trim().toLowerCase();
1033
- if (!q) return [];
1544
+ var fq = foldQuery(query);
1545
+ if (!fq) return [];
1546
+ var needle = { folded: fq }; // one folded query, reused for every text
1034
1547
  var rows = [];
1035
1548
  var index = {};
1036
1549
  var push = function (row, n) {
@@ -1043,44 +1556,83 @@ window.__ModuleLoader__.load({
1043
1556
  };
1044
1557
  for (var gi = 0; gi < groups.length; gi++) {
1045
1558
  var g = groups[gi];
1559
+ // a turn the paged window has not loaded: searchable through the host
1560
+ // outline's bounded previews, and clicking it pages the turn in first
1561
+ if (g.ghost) {
1562
+ if (searchScope === "full") {
1563
+ var gtext = (g.userText || "") + "\n" + (g.preview || "");
1564
+ var ng = countOccurrences(gtext, needle, fuzzy);
1565
+ if (ng > 0) {
1566
+ push({
1567
+ gi: gi, key: "", idx: undefined,
1568
+ title: previewText(g.userText || g.preview || "", 40),
1569
+ level: 0, path: "", time: "",
1570
+ snippet: snippetAround(g.preview || g.userText || "", fq, 36, fuzzy),
1571
+ ghost: true, turn: g.turn, seq: g.seq
1572
+ }, ng);
1573
+ }
1574
+ }
1575
+ continue;
1576
+ }
1046
1577
  for (var j = 0; j < g.headings.length; j++) {
1047
1578
  var h = g.headings[j];
1048
- var nh = countOccurrences(h.title, q);
1579
+ var nh = countOccurrences(h.title, needle, fuzzy);
1049
1580
  if (nh > 0) {
1050
1581
  var hp = headingPaths[headingId(h)];
1051
1582
  push({
1052
1583
  gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
1053
- path: hp ? hp.path : "", time: g.time, snippet: ""
1584
+ // the subtitle identifies same-titled headings ("which one?")
1585
+ path: hp ? hp.path : "", time: g.time, snippet: h.sub || ""
1054
1586
  }, nh);
1055
1587
  }
1056
1588
  }
1057
1589
  if (searchScope === "full") {
1058
- var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, q) : 0;
1590
+ var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, needle, fuzzy) : 0;
1059
1591
  if (nu > 0) {
1060
1592
  push({
1061
1593
  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)
1594
+ level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, fq, 36, fuzzy)
1063
1595
  }, nu);
1064
1596
  }
1065
1597
  for (var m = 0; m < g.msgs.length; m++) {
1066
1598
  var msg = g.msgs[m];
1067
1599
  if (!msg.text) continue;
1068
- var nm = countOccurrences(msg.text, q);
1600
+ var nm = countOccurrences(msg.text, needle, fuzzy);
1069
1601
  if (nm === 0) continue;
1070
1602
  push({
1071
1603
  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)
1604
+ level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, fq, 36, fuzzy)
1073
1605
  }, nm);
1074
1606
  }
1075
1607
  }
1076
1608
  }
1077
1609
  return rows;
1078
- }, [groups, query, searchScope, headingPaths]);
1079
- headingPathsRef.current = headingPaths;
1610
+ }, [groups, query, searchScope, headingPaths, fuzzy]);
1611
+ // ---- auto-follow: which turn is the reader looking at -------------------
1612
+ // This used to hang off ONE scroll listener bound to the scrollport element
1613
+ // captured when the effect ran. DSH can replace that element (a jump that
1614
+ // repages the window remounts the list), and the listener then stays on the
1615
+ // detached node — the outline silently stops following. So it re-queries the
1616
+ // scrollport on every run, listens on the DOCUMENT in the capture phase
1617
+ // (scroll does not bubble; capture still sees every descendant), and keeps a
1618
+ // slow poll for programmatic anchor adjustments that emit no scroll event.
1619
+ //
1620
+ // The outline's own scroll is INSTANT and starts on the next frame: a following
1621
+ // rail that animates its way to the target reads as permanently one step behind.
1080
1622
  react.useEffect(function () {
1081
- var sp = document.querySelector("[data-conversation-scroll]");
1082
- if (!sp) return;
1083
- var update = function () {
1623
+ var followFrame = null;
1624
+ var followTimer = null;
1625
+ var lastRun = 0;
1626
+ var update = function (force) {
1627
+ // scroll frames are hot and a document-level capture listener sees every
1628
+ // scroll in the app; the rail only changes when a different turn crosses
1629
+ // the viewport, so cap the row measurement at ~16/s
1630
+ var now = Date.now();
1631
+ if (!force && now - lastRun < 60) return;
1632
+ lastRun = now;
1633
+ if (!chatViewRef.current) return;
1634
+ var sp = document.querySelector("[data-conversation-scroll]");
1635
+ if (!sp) return;
1084
1636
  var lr = sp.getBoundingClientRect();
1085
1637
  var vTop = lr.top;
1086
1638
  var vBottom = lr.top + lr.height;
@@ -1096,74 +1648,43 @@ window.__ModuleLoader__.load({
1096
1648
  }
1097
1649
  }
1098
1650
  var sig = actives.slice().sort().join(",");
1099
- if (sig !== activeSigRef.current) {
1100
- activeSigRef.current = sig;
1101
- setActiveGroup(actives);
1102
- // auto-follow: keep the reading position visible in the outline —
1103
- // but pause for ~2s after the user touches the outline themselves,
1104
- // otherwise loading older turns gets yanked back to the bottom
1105
- if (actives.length > 0 && Date.now() - outlineTouchRef.current > 2000) {
1106
- var gi0 = actives[0];
1107
- // ensure the group's window is loaded (with a small buffer below)
1108
- setVisibleCount(function (prev) {
1109
- var need = groups.length - gi0 + 3;
1110
- return Math.max(prev, Math.min(groups.length, need));
1111
- });
1112
- var el = listRef.current;
1113
- if (el) {
1114
- setTimeout(function () {
1115
- var node = el.querySelector('[data-group-idx="' + gi0 + '"]');
1116
- if (!node) return;
1117
- var er = node.getBoundingClientRect();
1118
- var lr2 = el.getBoundingClientRect();
1119
- if (er.top < lr2.top - 2 || er.bottom > lr2.bottom + 2) {
1120
- el.scrollTo({ top: el.scrollTop + (er.top - lr2.top) - el.clientHeight / 2 + node.offsetHeight / 2, behavior: "smooth" });
1121
- }
1122
- }, 120);
1123
- }
1124
- }
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;
1651
+ if (sig === activeSigRef.current) return;
1652
+ activeSigRef.current = sig;
1653
+ setActiveGroup(actives);
1654
+ if (actives.length === 0) return;
1655
+ // pause right after the reader touched the outline themselves
1656
+ // otherwise paging older turns gets yanked back to the newest group
1657
+ if (Date.now() - outlineTouchRef.current <= 1500) return;
1658
+ var gi0 = actives[0];
1659
+ setVisibleCount(function (prev) {
1660
+ var need = groups.length - gi0 + 3;
1661
+ return Math.max(prev, Math.min(groups.length, need));
1662
+ });
1663
+ if (followFrame) cancelAnimationFrame(followFrame);
1664
+ if (followTimer) clearTimeout(followTimer);
1665
+ var place = function () {
1666
+ var el = listRef.current;
1667
+ if (!el) return;
1668
+ var node = el.querySelector('[data-group-idx="' + gi0 + '"]');
1669
+ if (!node) return;
1670
+ var er = node.getBoundingClientRect();
1671
+ var lr2 = el.getBoundingClientRect();
1672
+ if (er.top < lr2.top - 2 || er.bottom > lr2.bottom + 2) {
1673
+ el.scrollTop = el.scrollTop + (er.top - lr2.top) - el.clientHeight / 2 + node.offsetHeight / 2;
1154
1674
  }
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
- }
1675
+ };
1676
+ followFrame = requestAnimationFrame(function () { followFrame = requestAnimationFrame(place); });
1677
+ followTimer = setTimeout(place, 90); // the row may only exist after the expansion commits
1678
+ };
1679
+ update(true);
1680
+ document.addEventListener("scroll", update, { passive: true, capture: true });
1681
+ var poll = setInterval(function () { update(true); }, 250);
1682
+ return function () {
1683
+ document.removeEventListener("scroll", update, { capture: true });
1684
+ clearInterval(poll);
1685
+ if (followFrame) cancelAnimationFrame(followFrame);
1686
+ if (followTimer) clearTimeout(followTimer);
1163
1687
  };
1164
- update();
1165
- sp.addEventListener("scroll", update, { passive: true });
1166
- return function () { sp.removeEventListener("scroll", update); };
1167
1688
  }, [keyToGroup]);
1168
1689
 
1169
1690
  // ---- search matches ----
@@ -1172,21 +1693,32 @@ window.__ModuleLoader__.load({
1172
1693
  // Every occurrence counts (multiple hits inside one message = multiple
1173
1694
  // matches), so the n/N counter reflects the real total.
1174
1695
  var matches = react.useMemo(function () {
1175
- var q = query.trim().toLowerCase();
1176
- if (!q) return [];
1696
+ var fq = foldQuery(query);
1697
+ if (!fq) return [];
1698
+ var needle = { folded: fq };
1177
1699
  var out = [];
1178
1700
  for (var gi = 0; gi < groups.length; gi++) {
1179
1701
  var g = groups[gi];
1702
+ if (g.ghost) {
1703
+ if (searchScope === "full") {
1704
+ var gtext = (g.userText || "") + "\n" + (g.preview || "");
1705
+ var ng = countOccurrences(gtext, needle, fuzzy);
1706
+ for (var cg = 0; cg < ng; cg++) {
1707
+ out.push({ gi: gi, title: g.userText || "", key: "", idx: undefined, ghost: true, turn: g.turn, seq: g.seq });
1708
+ }
1709
+ }
1710
+ continue;
1711
+ }
1180
1712
  for (var j = 0; j < g.headings.length; j++) {
1181
1713
  var h = g.headings[j];
1182
- var n = countOccurrences(h.title, q);
1714
+ var n = countOccurrences(h.title, needle, fuzzy);
1183
1715
  for (var c = 0; c < n; c++) {
1184
1716
  out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
1185
1717
  }
1186
1718
  }
1187
1719
  if (searchScope === "full") {
1188
1720
  if (g.userKey && g.userFull) {
1189
- var nu = countOccurrences(g.userFull, q);
1721
+ var nu = countOccurrences(g.userFull, needle, fuzzy);
1190
1722
  for (var cu = 0; cu < nu; cu++) {
1191
1723
  out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
1192
1724
  }
@@ -1194,7 +1726,7 @@ window.__ModuleLoader__.load({
1194
1726
  for (var m = 0; m < g.msgs.length; m++) {
1195
1727
  var msg = g.msgs[m];
1196
1728
  if (!msg.text) continue;
1197
- var nm = countOccurrences(msg.text, q);
1729
+ var nm = countOccurrences(msg.text, needle, fuzzy);
1198
1730
  for (var cm = 0; cm < nm; cm++) {
1199
1731
  out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
1200
1732
  }
@@ -1202,7 +1734,7 @@ window.__ModuleLoader__.load({
1202
1734
  }
1203
1735
  }
1204
1736
  return out;
1205
- }, [groups, query, searchScope]);
1737
+ }, [groups, query, searchScope, fuzzy]);
1206
1738
 
1207
1739
  // which result row holds the current (n/N) occurrence (needs `matches`)
1208
1740
  var activeResultRow = -1;
@@ -1225,10 +1757,13 @@ window.__ModuleLoader__.load({
1225
1757
  var q = query.trim();
1226
1758
  if (searchPosRef.current === q) return;
1227
1759
  searchPosRef.current = q;
1228
- if (!q) return;
1760
+ if (!q) { setHint(""); return; }
1229
1761
  var el = listRef.current;
1230
1762
  if (el) el.scrollTop = el.scrollHeight;
1231
1763
  if (matches.length > 0) setMatchIdx(matches.length - 1);
1764
+ // arm the "scroll up for older messages" hint: the result list only covers
1765
+ // what the window holds, and older turns are one upward scroll away
1766
+ setHint(canLoadOlder() ? "more" : "");
1232
1767
  }, [query, matches.length]);
1233
1768
 
1234
1769
  // keep the current row in view while stepping with Enter
@@ -1299,15 +1834,8 @@ window.__ModuleLoader__.load({
1299
1834
  // ---- jump: smooth glide to the exact heading element. When glued to the
1300
1835
  // bottom, lift just past DSH's 25px stick-to-bottom threshold first so
1301
1836
  // the glide is not yanked back. ----
1302
- var jump = function (key, idx) {
1303
- var row = findRow(key);
1304
- if (!row) return;
1305
- var el = row;
1306
- if (idx !== undefined && idx !== null) {
1307
- var hs = row.querySelectorAll("h1, h2, h3, h4, h5, h6");
1308
- if (hs.length > 0) el = hs[Math.min(idx, hs.length - 1)] || row;
1309
- }
1310
- var sp = row.closest ? row.closest("[data-conversation-scroll]") : null;
1837
+ var glideTo = function (el) {
1838
+ var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
1311
1839
  if (!sp) {
1312
1840
  el.scrollIntoView({ behavior: "smooth", block: "start" });
1313
1841
  return;
@@ -1320,6 +1848,104 @@ window.__ModuleLoader__.load({
1320
1848
  sp.scrollTo({ top: t, behavior: "smooth" });
1321
1849
  };
1322
1850
 
1851
+ var jump = function (key, idx) {
1852
+ var row = findRow(key);
1853
+ if (!row) return;
1854
+ var el = row;
1855
+ if (idx !== undefined && idx !== null) {
1856
+ var hs = row.querySelectorAll("h1, h2, h3, h4, h5, h6");
1857
+ if (hs.length > 0) el = hs[Math.min(idx, hs.length - 1)] || row;
1858
+ }
1859
+ glideTo(el);
1860
+ };
1861
+
1862
+ // ---- unloaded turns: page history to the turn, then land on it ----------
1863
+ // The host turn outline carries each turn's `turn/start` seq and the session
1864
+ // face's loadThrough(seq) is the documented jump loader ("page history
1865
+ // backwards until the window covers seq"). Once it resolves the turn's nodes
1866
+ // exist, so we poll briefly for its row — the native turn rail waits for the
1867
+ // same settle.
1868
+ var turnRow = function (turn) {
1869
+ try {
1870
+ return document.querySelector('[data-chat-turn="' + String(turn) + '"]');
1871
+ } catch (e) {
1872
+ return null;
1873
+ }
1874
+ };
1875
+
1876
+ // Fallback locator for the poll below: once the paged window covers the
1877
+ // turn, OUR OWN rebuilt outline has a loaded group for it, which carries the
1878
+ // anchor keys of that turn's nodes. That path does not depend on the host
1879
+ // keeping a `data-chat-turn` attribute on the row.
1880
+ var groupsRef = react.useRef([]);
1881
+ react.useEffect(function () { groupsRef.current = groups; }, [groups]);
1882
+ var loadedGroupRow = function (turn) {
1883
+ var list = groupsRef.current || [];
1884
+ for (var i = 0; i < list.length; i++) {
1885
+ var g = list[i];
1886
+ if (g.ghost || g.turn !== turn) continue;
1887
+ var key = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : g.userKey;
1888
+ var row = key ? findRow(key) : null;
1889
+ if (row) return row;
1890
+ }
1891
+ return null;
1892
+ };
1893
+
1894
+ var landOnTurn = function (turn) {
1895
+ var tries = 0;
1896
+ var step = function () {
1897
+ tries++;
1898
+ var row = turnRow(turn) || loadedGroupRow(turn);
1899
+ if (row) {
1900
+ glideTo(row);
1901
+ // a jump that started from a search hit keeps the hit highlighted
1902
+ if (query.trim()) {
1903
+ clearHighlights();
1904
+ highlightRow(row, query.trim(), 0, fuzzy);
1905
+ }
1906
+ return;
1907
+ }
1908
+ if (tries < 25) setTimeout(step, 80);
1909
+ };
1910
+ setTimeout(step, 40);
1911
+ };
1912
+
1913
+ // returns false when the host exposes no jump loader (older DSH builds)
1914
+ var openTurn = function (turn, seq) {
1915
+ var sessions = hostSessions();
1916
+ if (!sessions) {
1917
+ console.warn("[dsh-quick-toc] ctx.get(\"sessions\") returned nothing: this host has no session service, cannot open turn " + turn);
1918
+ return false;
1919
+ }
1920
+ var binding = typeof sessions.binding === "function" ? sessions.binding(sessionId) : null;
1921
+ var face = binding && binding.session;
1922
+ if (!face || typeof face.loadThrough !== "function") {
1923
+ console.warn("[dsh-quick-toc] session " + String(sessionId) + " exposes no loadThrough jump loader, cannot open turn " + turn);
1924
+ return false;
1925
+ }
1926
+ setHoverCard(null);
1927
+ Promise.resolve(face.loadThrough(seq)).then(function () {
1928
+ landOnTurn(turn);
1929
+ }, function (err) {
1930
+ console.warn("[dsh-quick-toc] loadThrough failed:", err);
1931
+ });
1932
+ return true;
1933
+ };
1934
+
1935
+ // reveal one group inside the outline list (shared by Enter-stepping and by
1936
+ // jumps into a turn that had to be loaded first)
1937
+ var revealGroupInOutline = function (gi) {
1938
+ setVisibleCount(function (prev) {
1939
+ return Math.max(prev, Math.min(groups.length, groups.length - gi));
1940
+ });
1941
+ setTimeout(function () {
1942
+ var el = listRef.current;
1943
+ if (!el) return;
1944
+ var node = el.querySelector('[data-group-idx="' + gi + '"]');
1945
+ if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
1946
+ }, 150);
1947
+ };
1948
+
1323
1949
  // jump to the n-th match, cycling; also reveal the group in the outline.
1324
1950
  // ONE smooth scroll straight to the current occurrence (no competing
1325
1951
  // scrolls): highlight first, then position the mark at the upper-middle
@@ -1328,14 +1954,21 @@ window.__ModuleLoader__.load({
1328
1954
  var i = ((n % matches.length) + matches.length) % matches.length;
1329
1955
  setMatchIdx(i);
1330
1956
  var m = matches[i];
1331
- var q = query.trim().toLowerCase();
1957
+ var q = query.trim();
1332
1958
  clearHighlights();
1959
+ if (m.ghost) {
1960
+ // the hit lives in a turn the paged window has not loaded: page it in
1961
+ // and land on it (landOnTurn re-applies the highlight there)
1962
+ openTurn(m.turn, m.seq);
1963
+ revealGroupInOutline(m.gi);
1964
+ return;
1965
+ }
1333
1966
  var r = findRowStrict(m.key);
1334
1967
  if (r && q) {
1335
1968
  // occurrence index within the target message (consecutive in matches)
1336
1969
  var occ = 0;
1337
1970
  for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
1338
- highlightRow(r, q, occ);
1971
+ highlightRow(r, q, occ, fuzzy);
1339
1972
  var markEl = r.querySelector(".dqt-current");
1340
1973
  var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
1341
1974
  if (sp && markEl) {
@@ -1352,15 +1985,7 @@ window.__ModuleLoader__.load({
1352
1985
  } else {
1353
1986
  jump(m.key, m.idx);
1354
1987
  }
1355
- setVisibleCount(function (prev) {
1356
- return Math.max(prev, Math.min(groups.length, groups.length - m.gi));
1357
- });
1358
- setTimeout(function () {
1359
- var el = listRef.current;
1360
- if (!el) return;
1361
- var node = el.querySelector('[data-group-idx="' + m.gi + '"]');
1362
- if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
1363
- }, 150);
1988
+ revealGroupInOutline(m.gi);
1364
1989
  };
1365
1990
 
1366
1991
  // clicking result row `i` = make its FIRST occurrence the current match, so
@@ -1417,6 +2042,47 @@ window.__ModuleLoader__.load({
1417
2042
  };
1418
2043
 
1419
2044
  // ---- panel ----
2045
+ // what the module-scope render helpers need from this render
2046
+ var renderExtra = {
2047
+ paths: headingPaths,
2048
+ fuzzy: fuzzy,
2049
+ onOpenTurn: openTurn,
2050
+ hoverStart: hoverStart,
2051
+ hoverEnd: hoverEnd
2052
+ };
2053
+ // Transient banner pinned to the bottom of the panel (the panel is
2054
+ // position:fixed, so this anchors to the list's lower edge without joining
2055
+ // the list's scroll flow). pointerEvents:none keeps it from eating clicks.
2056
+ var toastEl = toast ? react_jsx_runtime.jsx("div", {
2057
+ className: "dqt-toast" + (toast.closing ? " dqt-toast-closing" : ""),
2058
+ style: {
2059
+ position: "absolute",
2060
+ left: 8,
2061
+ right: 8,
2062
+ bottom: 8,
2063
+ display: "flex",
2064
+ justifyContent: "center",
2065
+ pointerEvents: "none",
2066
+ zIndex: 3
2067
+ },
2068
+ children: react_jsx_runtime.jsx("span", {
2069
+ style: {
2070
+ maxWidth: "100%",
2071
+ padding: "3px 10px",
2072
+ borderRadius: 999,
2073
+ cornerShape: "round",
2074
+ fontSize: 11,
2075
+ color: C.text,
2076
+ background: C.panelBg,
2077
+ border: "1px solid " + C.panelBorder,
2078
+ boxShadow: "0 2px 10px rgba(0,0,0,0.22)",
2079
+ whiteSpace: "nowrap",
2080
+ overflow: "hidden",
2081
+ textOverflow: "ellipsis"
2082
+ },
2083
+ children: toast.text
2084
+ })
2085
+ }) : null;
1420
2086
  // panel: opening slides in with a slow fade; closing slides quickly to
1421
2087
  // the dock edge, clipped by the sidebar line (looks covered, not
1422
2088
  // dissolving) and only fades at the very end. No box-shadow: a shadow
@@ -1441,7 +2107,12 @@ window.__ModuleLoader__.load({
1441
2107
  }
1442
2108
  }
1443
2109
  // faded out entirely while another center-column view is active
1444
- var panelOpacity = open && chatViewActive ? (hovered ? 0.95 : 0.45) : 0;
2110
+ // Idle transparency used to be 0.45, which stacked with the outline's own
2111
+ // inactive-group dimming (0.6) into a wall of grey that left the panel barely
2112
+ // readable whenever the pointer was elsewhere. Readability now comes from
2113
+ // CONTRAST on the group being read (tinted row + accent bar, see
2114
+ // renderGroups) instead of from fading everything else away.
2115
+ var panelOpacity = open && chatViewActive ? (hovered ? 1 : 0.72) : 0;
1445
2116
 
1446
2117
  var panelEl = react_jsx_runtime.jsx("div", {
1447
2118
  style: {
@@ -1491,7 +2162,7 @@ window.__ModuleLoader__.load({
1491
2162
  cursor: "grab",
1492
2163
  userSelect: "none"
1493
2164
  },
1494
- title: "按住拖动调整位置",
2165
+ title: T("handle.dragY"),
1495
2166
  onPointerDown: onHandleDown,
1496
2167
  onMouseEnter: function () { handleBright(true); },
1497
2168
  onMouseLeave: function () { handleBright(false); },
@@ -1524,7 +2195,7 @@ window.__ModuleLoader__.load({
1524
2195
  // three-bar outline mark (long / medium / short) — no title text
1525
2196
  react_jsx_runtime.jsx("div", {
1526
2197
  style: { display: "flex", alignItems: "center", color: C.muted, flex: "none" },
1527
- title: "对话大纲",
2198
+ title: T("panel.title"),
1528
2199
  children: react_jsx_runtime.jsx("svg", {
1529
2200
  width: 18,
1530
2201
  height: 14,
@@ -1540,33 +2211,61 @@ window.__ModuleLoader__.load({
1540
2211
  react_jsx_runtime.jsx("div", {
1541
2212
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
1542
2213
  children: [
1543
- // collapse/expand the H1–H6 level-filter row (sits directly
1544
- // left of the search button). Labelled, not a bare icon.
2214
+ // heading-level filter: a round icon button (directly left
2215
+ // of the search button) that pops down the H1–H6 picker.
2216
+ // Icon: three lines of decreasing width = outline levels.
2217
+ // Hover behaviour matches the magnifier: background + icon
2218
+ // tint change; the resting look never shifts when open.
1545
2219
  react_jsx_runtime.jsx("button", {
1546
- onClick: toggleLevelsRow,
1547
- title: showLevelsRow ? "收起 H1–H6 层级筛选那一行" : "展开 H1–H6 层级筛选那一行",
2220
+ className: "dqt-levels-btn",
2221
+ onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
2222
+ title: T("levels.tip"),
1548
2223
  style: {
1549
- height: 20,
1550
- padding: "0 6px",
2224
+ width: 24,
2225
+ height: 24,
2226
+ padding: 0,
1551
2227
  border: "none",
1552
- borderRadius: 5,
2228
+ borderRadius: "50%",
1553
2229
  cornerShape: "round",
1554
- fontFamily: "inherit",
1555
- fontSize: 11,
1556
- lineHeight: "20px",
1557
2230
  cursor: "pointer",
1558
2231
  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
2232
+ display: "flex",
2233
+ alignItems: "center",
2234
+ justifyContent: "center",
2235
+ // open (or still animating shut) uses the SAME tint as every
2236
+ // other "on" control, so the header reads consistently
2237
+ background: (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
2238
+ color: (levelsOpen || levelsClosing) ? C.accent : C.muted,
2239
+ transition: "background 0.15s ease, color 0.15s ease"
2240
+ },
2241
+ onMouseEnter: function (e) {
2242
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
2243
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
2244
+ },
2245
+ onMouseLeave: function (e) {
2246
+ e.currentTarget.style.background = (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
2247
+ e.currentTarget.style.color = (levelsOpen || levelsClosing) ? C.accent : C.muted;
1563
2248
  },
1564
- children: "层级"
2249
+ children: react_jsx_runtime.jsx("svg", {
2250
+ width: 14,
2251
+ height: 14,
2252
+ viewBox: "0 0 14 14",
2253
+ fill: "none",
2254
+ stroke: "currentColor",
2255
+ strokeWidth: 1.8,
2256
+ strokeLinecap: "round",
2257
+ style: { display: "block" },
2258
+ children: [
2259
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 3, x2: 12.5, y2: 3 }),
2260
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 7, x2: 9, y2: 7 }),
2261
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 11, x2: 5.5, y2: 11 })
2262
+ ]
2263
+ })
1565
2264
  }),
1566
2265
  // magnifier button (SVG, matches the other buttons' style)
1567
2266
  react_jsx_runtime.jsx("button", {
1568
2267
  onClick: function () { searchOpen ? closeSearch() : openSearch(); },
1569
- title: "搜索标题",
2268
+ title: T("search.open"),
1570
2269
  style: {
1571
2270
  width: 24,
1572
2271
  height: 24,
@@ -1575,9 +2274,9 @@ window.__ModuleLoader__.load({
1575
2274
  justifyContent: "center",
1576
2275
  borderRadius: "50%",
1577
2276
  cornerShape: "round",
1578
- background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
2277
+ background: searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
1579
2278
  border: "none",
1580
- color: C.muted,
2279
+ color: searchOpen ? C.accent : C.muted,
1581
2280
  cursor: "pointer"
1582
2281
  },
1583
2282
  onMouseEnter: function (e) {
@@ -1585,8 +2284,8 @@ window.__ModuleLoader__.load({
1585
2284
  e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
1586
2285
  },
1587
2286
  onMouseLeave: function (e) {
1588
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
1589
- e.currentTarget.style.color = C.muted;
2287
+ e.currentTarget.style.background = searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
2288
+ e.currentTarget.style.color = searchOpen ? C.accent : C.muted;
1590
2289
  },
1591
2290
  children: react_jsx_runtime.jsx("svg", {
1592
2291
  width: 15,
@@ -1606,11 +2305,11 @@ window.__ModuleLoader__.load({
1606
2305
  })
1607
2306
  }),
1608
2307
  // triangle tips toward the side it will move TO: shift slightly up + toward the tip
1609
- iconBtn(toggleDock, dockRight ? "移到左侧" : "移到右侧", dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 }),
2308
+ iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 }),
1610
2309
  // close: thick SVG cross, nudged slightly down
1611
2310
  react_jsx_runtime.jsx("button", {
1612
2311
  onClick: function () { setOpen(false); },
1613
- title: "收起",
2312
+ title: T("handle.collapse"),
1614
2313
  style: {
1615
2314
  width: 24,
1616
2315
  height: 24,
@@ -1654,67 +2353,6 @@ window.__ModuleLoader__.load({
1654
2353
  })
1655
2354
  ]
1656
2355
  }),
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,
1718
2356
  // search input row: outer grid row animates 0fr<->1fr so the row
1719
2357
  // collapses/expands to its EXACT natural height (no max-height
1720
2358
  // overshoot stutter); the outline below moves smoothly with it
@@ -1783,23 +2421,23 @@ window.__ModuleLoader__.load({
1783
2421
  color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
1784
2422
  },
1785
2423
  children: [
1786
- "搜索",
2424
+ T("search.word"),
1787
2425
  react_jsx_runtime.jsx("span", {
1788
2426
  style: { position: "relative", display: "inline-block" },
1789
2427
  children: [
1790
2428
  react_jsx_runtime.jsx("span", {
1791
2429
  key: searchScope,
1792
2430
  style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
1793
- children: searchScope === "title" ? "标题" : "全文"
2431
+ children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
1794
2432
  }),
1795
2433
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1796
2434
  key: "old-" + prevScope,
1797
2435
  style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
1798
- children: prevScope === "title" ? "标题" : "全文"
2436
+ children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
1799
2437
  }) : null
1800
2438
  ]
1801
2439
  }),
1802
- ",回车定位…"
2440
+ T("search.tail")
1803
2441
  ]
1804
2442
  }) : null,
1805
2443
  ]
@@ -1828,7 +2466,7 @@ window.__ModuleLoader__.load({
1828
2466
  // label text cross-fades old->new)
1829
2467
  react_jsx_runtime.jsx("button", {
1830
2468
  onClick: toggleScope,
1831
- title: searchScope === "title" ? "当前:仅搜索标题。点击切换为全文搜索" : "当前:全文搜索。点击切换为仅标题",
2469
+ title: searchScope === "title" ? T("search.scope.tipTitle") : T("search.scope.tipFull"),
1832
2470
  style: {
1833
2471
  flex: "none",
1834
2472
  width: 34,
@@ -1840,7 +2478,7 @@ window.__ModuleLoader__.load({
1840
2478
  cornerShape: "round",
1841
2479
  fontSize: 11,
1842
2480
  color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1843
- background: searchScope === "full" ? "rgba(79,140,255,0.18)" : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
2481
+ background: searchScope === "full" ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1844
2482
  border: "none",
1845
2483
  cursor: "pointer",
1846
2484
  whiteSpace: "nowrap",
@@ -1852,15 +2490,40 @@ window.__ModuleLoader__.load({
1852
2490
  react_jsx_runtime.jsx("span", {
1853
2491
  key: searchScope,
1854
2492
  style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
1855
- children: searchScope === "title" ? "标题" : "全文"
2493
+ children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
1856
2494
  }),
1857
2495
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1858
2496
  key: "old-" + prevScope,
1859
2497
  style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
1860
- children: prevScope === "title" ? "标题" : "全文"
2498
+ children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
1861
2499
  }) : null
1862
2500
  ]
1863
2501
  })
2502
+ }),
2503
+ // fuzzy switch: an independent toggle beside the scope pill (its label
2504
+ // is the same either way, so only the colour carries the state)
2505
+ react_jsx_runtime.jsx("button", {
2506
+ onClick: toggleFuzzy,
2507
+ title: fuzzy ? T("search.fuzzy.tipOn") : T("search.fuzzy.tipOff"),
2508
+ "data-fuzzy": fuzzy ? "on" : "off",
2509
+ style: {
2510
+ flex: "none",
2511
+ width: 34,
2512
+ height: 34,
2513
+ display: "flex",
2514
+ alignItems: "center",
2515
+ justifyContent: "center",
2516
+ borderRadius: "50%",
2517
+ cornerShape: "round",
2518
+ fontSize: 11,
2519
+ color: fuzzy ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
2520
+ background: fuzzy ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
2521
+ border: "none",
2522
+ cursor: "pointer",
2523
+ whiteSpace: "nowrap",
2524
+ transition: "background 0.25s ease, color 0.25s ease"
2525
+ },
2526
+ children: T("search.fuzzy")
1864
2527
  })
1865
2528
  ]
1866
2529
  })
@@ -1887,10 +2550,32 @@ window.__ModuleLoader__.load({
1887
2550
  children: react_jsx_runtime.jsx("div", {
1888
2551
  style: { direction: "ltr" },
1889
2552
  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)
2553
+ ? [
2554
+ react_jsx_runtime.jsx("div", {
2555
+ key: "results",
2556
+ children: renderResults(resultRows, query.trim(), C, goToRow, activeResultRow, renderExtra)
2557
+ }),
2558
+ // older history is one upward scroll away — say so until the
2559
+ // reader actually scrolls up (then the hint has done its job)
2560
+ hint ? react_jsx_runtime.jsx("div", {
2561
+ key: "hint",
2562
+ style: {
2563
+ display: "flex",
2564
+ alignItems: "center",
2565
+ justifyContent: "center",
2566
+ padding: "10px 6px 4px",
2567
+ fontSize: 11,
2568
+ color: C.muted,
2569
+ opacity: 0.85,
2570
+ textAlign: "center"
2571
+ },
2572
+ children: hint === "oldest" ? T("search.hintOldest") : T("search.hintMore")
2573
+ }) : null
2574
+ ]
2575
+ : renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup, renderExtra)
1892
2576
  })
1893
2577
  }),
2578
+ toastEl,
1894
2579
  // resize handles (right edge: width, bottom edge: height)
1895
2580
  react_jsx_runtime.jsx("div", {
1896
2581
  style: {
@@ -1904,7 +2589,7 @@ window.__ModuleLoader__.load({
1904
2589
  zIndex: 2
1905
2590
  },
1906
2591
  onPointerDown: onResizeWDown,
1907
- title: "拖拽调整宽度"
2592
+ title: T("resize.w")
1908
2593
  }),
1909
2594
  react_jsx_runtime.jsx("div", {
1910
2595
  style: {
@@ -1918,7 +2603,7 @@ window.__ModuleLoader__.load({
1918
2603
  zIndex: 2
1919
2604
  },
1920
2605
  onPointerDown: onResizeHDown,
1921
- title: "拖拽调整高度"
2606
+ title: T("resize.h")
1922
2607
  }),
1923
2608
  // corner handle: resize width AND height at once
1924
2609
  react_jsx_runtime.jsx("div", {
@@ -1933,8 +2618,59 @@ window.__ModuleLoader__.load({
1933
2618
  zIndex: 3
1934
2619
  },
1935
2620
  onPointerDown: onResizeCornerDown,
1936
- title: "拖拽同时调整宽高"
1937
- })
2621
+ title: T("resize.wh")
2622
+ }),
2623
+ // heading-level picker: pops down from the round header button,
2624
+ // right-aligned, with an outer shadow so it reads as a floating layer.
2625
+ // Open/close animate from the button's center: the animation lives in
2626
+ // the injected stylesheet (classes, not inline animation) so React
2627
+ // re-renders never restart or cancel it — an inline `animation` set on
2628
+ // every render made the entrance invisible and the exit never play.
2629
+ (levelsOpen || levelsClosing) ? react_jsx_runtime.jsx("div", {
2630
+ className: "dqt-levels-pop" + (levelsClosing ? " dqt-levels-pop-closing" : ""),
2631
+ style: {
2632
+ position: "absolute",
2633
+ top: 40,
2634
+ right: 8,
2635
+ zIndex: 30,
2636
+ display: "flex",
2637
+ alignItems: "center",
2638
+ flexWrap: "wrap",
2639
+ gap: 4,
2640
+ padding: 8,
2641
+ background: C.panelBg,
2642
+ border: "1px solid " + C.panelBorder,
2643
+ borderRadius: 10,
2644
+ cornerShape: "round",
2645
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
2646
+ maxWidth: "calc(100% - 16px)",
2647
+ transformOrigin: "calc(50% + 12px) top"
2648
+ },
2649
+ children: [1, 2, 3, 4, 5, 6].map(function (lv) {
2650
+ var active = !!levelSet[lv];
2651
+ return react_jsx_runtime.jsx("button", {
2652
+ onClick: function () { toggleLevel(lv); },
2653
+ title: active ? T("levels.hide") + lv : T("levels.show") + lv,
2654
+ style: {
2655
+ height: 22,
2656
+ padding: "0 7px",
2657
+ border: "none",
2658
+ borderRadius: 6,
2659
+ cornerShape: "round",
2660
+ cursor: "pointer",
2661
+ fontSize: 11,
2662
+ lineHeight: "22px",
2663
+ fontFamily: "inherit",
2664
+ background: active
2665
+ ? C.chip
2666
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
2667
+ color: active ? C.accent : C.muted,
2668
+ fontWeight: active ? 600 : 400
2669
+ },
2670
+ children: "H" + lv
2671
+ }, "lv" + lv);
2672
+ })
2673
+ }) : null
1938
2674
  ]
1939
2675
  });
1940
2676
 
@@ -1958,7 +2694,7 @@ window.__ModuleLoader__.load({
1958
2694
  ? { right: viewport ? viewport.right + 52 : 60 } // clear of the milestone rail
1959
2695
  : { left: viewport ? viewport.left : 0 })
1960
2696
  },
1961
- title: "展开大纲",
2697
+ title: T("handle.expand"),
1962
2698
  onClick: function () { setOpen(true); },
1963
2699
  onMouseEnter: function () { if (edgeRef.current) edgeRef.current.style.opacity = "1"; },
1964
2700
  onMouseLeave: function () { if (edgeRef.current) edgeRef.current.style.opacity = "0.5"; },
@@ -1993,29 +2729,119 @@ window.__ModuleLoader__.load({
1993
2729
  })
1994
2730
  }) : null;
1995
2731
 
2732
+ // ---- hover preview card ------------------------------------------------
2733
+ // Rendered INSIDE the body portal (position: fixed) so the outline's own
2734
+ // scroll container can never clip it, and pointer-events: none so it can
2735
+ // never steal the hover that opened it.
2736
+ var hoverEl = null;
2737
+ if (hoverCard && open) {
2738
+ var cardW = 264;
2739
+ var cardTop = Math.max(8, Math.min(hoverCard.top - 4, window.innerHeight - 210));
2740
+ var cardLeft = dockRight
2741
+ ? Math.max(8, hoverCard.left - cardW - 10)
2742
+ : Math.min(window.innerWidth - cardW - 8, hoverCard.right + 10);
2743
+ var cardBody = hoverCard.preview || hoverCard.sub || "";
2744
+ var cardChildren = [
2745
+ react_jsx_runtime.jsx("div", {
2746
+ style: { fontSize: 12, fontWeight: 600, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
2747
+ children: hoverCard.title
2748
+ }, "hc-t")
2749
+ ];
2750
+ if (hoverCard.meta) {
2751
+ cardChildren.push(react_jsx_runtime.jsx("div", {
2752
+ style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
2753
+ children: hoverCard.meta
2754
+ }, "hc-m"));
2755
+ }
2756
+ cardChildren.push(react_jsx_runtime.jsx("div", {
2757
+ style: { fontSize: 11, lineHeight: "16px", color: C.text, opacity: 0.9, marginTop: 4, maxHeight: 116, overflow: "hidden" },
2758
+ children: cardBody || (hoverCard.ghost ? T("turn.loadTip") : "")
2759
+ }, "hc-b"));
2760
+ if (hoverCard.ghost) {
2761
+ cardChildren.push(react_jsx_runtime.jsx("div", {
2762
+ style: { fontSize: 10, color: C.muted, opacity: 0.7, marginTop: 4 },
2763
+ children: T("preview.truncated")
2764
+ }, "hc-n"));
2765
+ }
2766
+ hoverEl = react_jsx_runtime.jsx("div", {
2767
+ className: "dqt-hover" + (hoverClosing ? " dqt-hover-closing" : ""),
2768
+ style: {
2769
+ position: "fixed",
2770
+ top: cardTop,
2771
+ left: cardLeft,
2772
+ width: cardW,
2773
+ padding: "8px 10px",
2774
+ borderRadius: 8,
2775
+ cornerShape: "round",
2776
+ background: C.panelBg,
2777
+ color: C.text,
2778
+ border: "1px solid " + C.panelBorder,
2779
+ boxShadow: "0 8px 24px rgba(0,0,0,0.28)",
2780
+ zIndex: Z_BASE + 1,
2781
+ pointerEvents: "none"
2782
+ },
2783
+ children: cardChildren
2784
+ }, "hover-card");
2785
+ }
2786
+
1996
2787
  return react_dom.createPortal(
1997
- react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl] }),
2788
+ react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl, hoverEl] }),
1998
2789
  document.body
1999
2790
  );
2000
2791
  }
2001
2792
 
2002
- function renderItem(n, depth, jump, C, uid) {
2793
+ function renderItem(n, depth, jump, C, uid, extra) {
2003
2794
  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"));
2795
+ var path = extra && extra.paths ? extra.paths[headingId(n)] : null;
2796
+ var hoverInfo = {
2797
+ title: n.title,
2798
+ sub: n.sub || "",
2799
+ preview: n.preview || "",
2800
+ meta: [n.time, path ? path.path : ""].filter(Boolean).join(" · "),
2801
+ ghost: false
2802
+ };
2803
+ var rows = [
2804
+ react_jsx_runtime.jsx("div", {
2805
+ style: { display: "flex", alignItems: "center", gap: 2, minWidth: 0 },
2806
+ children: react_jsx_runtime.jsx("span", {
2807
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
2808
+ children: n.title
2809
+ })
2810
+ }, uid + "-l")
2811
+ ];
2812
+ // Subtitle = the section's first sentence. This is what makes two headings
2813
+ // with the SAME text distinguishable without hovering ("which one is this?").
2814
+ if (n.sub) {
2815
+ rows.push(react_jsx_runtime.jsx("div", {
2816
+ style: {
2817
+ fontSize: 10,
2818
+ lineHeight: "13px",
2819
+ color: C.muted,
2820
+ opacity: 0.75,
2821
+ whiteSpace: "nowrap",
2822
+ overflow: "hidden",
2823
+ textOverflow: "ellipsis"
2824
+ },
2825
+ children: n.sub
2826
+ }, uid + "-s"));
2827
+ }
2009
2828
  return react_jsx_runtime.jsx(
2010
2829
  "div",
2011
2830
  {
2012
2831
  onClick: function () { jump(n.key, n.idx); },
2832
+ onMouseEnter: function (e) {
2833
+ e.currentTarget.style.background = C.hover;
2834
+ if (extra && extra.hoverStart) extra.hoverStart(hoverInfo, e.currentTarget);
2835
+ },
2836
+ onMouseLeave: function (e) {
2837
+ e.currentTarget.style.background = "transparent";
2838
+ if (extra && extra.hoverEnd) extra.hoverEnd();
2839
+ },
2013
2840
  "data-jump-key": n.key,
2014
2841
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
2015
2842
  style: {
2016
- display: "flex",
2017
- alignItems: "center",
2018
- gap: 2,
2843
+ // no fixed height: a row grows by one line when it has a subtitle
2844
+ display: "block",
2019
2845
  padding: "2px 6px",
2020
2846
  paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
2021
2847
  margin: "1px 0",
@@ -2025,26 +2851,23 @@ window.__ModuleLoader__.load({
2025
2851
  color: n.level <= 2 ? C.text : C.muted,
2026
2852
  fontWeight: n.level <= 2 ? 600 : 400,
2027
2853
  lineHeight: "18px",
2028
- height: 22,
2029
2854
  whiteSpace: "nowrap",
2030
2855
  overflow: "hidden"
2031
2856
  },
2032
- onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2033
- onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
2034
2857
  title: n.title,
2035
- children: parts
2858
+ children: rows
2036
2859
  },
2037
2860
  uid + "-" + n.level + "-" + (n.key || "")
2038
2861
  );
2039
2862
  }
2040
2863
 
2041
- function renderNodes(nodes, depth, jump, C, uid) {
2864
+ function renderNodes(nodes, depth, jump, C, uid, extra) {
2042
2865
  var out = [];
2043
2866
  for (var i = 0; i < nodes.length; i++) {
2044
2867
  var n = nodes[i];
2045
- out.push(renderItem(n, depth, jump, C, uid + "-" + i));
2868
+ out.push(renderItem(n, depth, jump, C, uid + "-" + i, extra));
2046
2869
  if (n.children && n.children.length) {
2047
- out.push(react_jsx_runtime.jsx("div", { children: renderNodes(n.children, depth + 1, jump, C, uid + "-" + i) }, uid + "-c-" + i));
2870
+ out.push(react_jsx_runtime.jsx("div", { children: renderNodes(n.children, depth + 1, jump, C, uid + "-" + i, extra) }, uid + "-c-" + i));
2048
2871
  }
2049
2872
  }
2050
2873
  return out;
@@ -2054,8 +2877,8 @@ window.__ModuleLoader__.load({
2054
2877
  // While a query is present the list shows every matched heading/message
2055
2878
  // (grouped per message, with an occurrence count) instead of the outline, so
2056
2879
  // search is "see them all, then jump" rather than stepping blindly.
2057
- function hitSpans(text, q) {
2058
- var parts = highlightParts(text, q);
2880
+ function hitSpans(text, q, fuzzy) {
2881
+ var parts = highlightParts(text, q, fuzzy);
2059
2882
  return parts.map(function (p, i) {
2060
2883
  return p.hit
2061
2884
  ? react_jsx_runtime.jsx("span", {
@@ -2066,8 +2889,10 @@ window.__ModuleLoader__.load({
2066
2889
  });
2067
2890
  }
2068
2891
 
2069
- function renderResultRow(r, i, q, C, onRow, isActive) {
2892
+ function renderResultRow(r, i, q, C, onRow, isActive, extra) {
2893
+ var fuzzy = !!(extra && extra.fuzzy);
2070
2894
  var meta = [];
2895
+ if (r.ghost) meta.push(T("turn.unloaded"));
2071
2896
  if (r.time) meta.push(r.time);
2072
2897
  if (r.path) meta.push(r.path);
2073
2898
  if (r.count > 1) meta.push("×" + r.count);
@@ -2081,7 +2906,7 @@ window.__ModuleLoader__.load({
2081
2906
  overflow: "hidden",
2082
2907
  textOverflow: "ellipsis"
2083
2908
  },
2084
- children: hitSpans(r.title, q)
2909
+ children: hitSpans(r.title, q, fuzzy)
2085
2910
  }, "t" + i)
2086
2911
  ];
2087
2912
  if (r.snippet) {
@@ -2094,7 +2919,7 @@ window.__ModuleLoader__.load({
2094
2919
  overflow: "hidden",
2095
2920
  textOverflow: "ellipsis"
2096
2921
  },
2097
- children: hitSpans(r.snippet, q)
2922
+ children: hitSpans(r.snippet, q, fuzzy)
2098
2923
  }, "s" + i));
2099
2924
  }
2100
2925
  if (meta.length) {
@@ -2108,9 +2933,23 @@ window.__ModuleLoader__.load({
2108
2933
  // keyword in the conversation and scrolls there, exactly like Enter
2109
2934
  // stepping — a row that only scrolled (no highlight) was the old bug
2110
2935
  onClick: function () { onRow(i); },
2936
+ onMouseEnter: function (e) {
2937
+ e.currentTarget.style.background = C.hover;
2938
+ if (extra && extra.hoverStart) {
2939
+ extra.hoverStart({
2940
+ title: r.title,
2941
+ sub: r.snippet || "",
2942
+ preview: r.snippet || "",
2943
+ meta: meta.join(" · "),
2944
+ ghost: !!r.ghost
2945
+ }, e.currentTarget);
2946
+ }
2947
+ },
2948
+ onMouseLeave: function (e) {
2949
+ e.currentTarget.style.background = isActive ? C.chip : "transparent";
2950
+ if (extra && extra.hoverEnd) extra.hoverEnd();
2951
+ },
2111
2952
  "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
2953
  title: r.path || r.title,
2115
2954
  style: {
2116
2955
  padding: "4px 6px",
@@ -2118,23 +2957,23 @@ window.__ModuleLoader__.load({
2118
2957
  borderRadius: 6,
2119
2958
  cursor: "pointer",
2120
2959
  minWidth: 0,
2121
- background: isActive ? "rgba(79,140,255,0.16)" : "transparent",
2960
+ background: isActive ? C.chip : "transparent",
2122
2961
  transition: "background 0.15s ease"
2123
2962
  },
2124
2963
  children: children
2125
2964
  }, "res-" + i);
2126
2965
  }
2127
2966
 
2128
- function renderResults(rows, q, C, onRow, activeRow) {
2967
+ function renderResults(rows, q, C, onRow, activeRow, extra) {
2129
2968
  if (!rows || rows.length === 0) {
2130
2969
  return react_jsx_runtime.jsx("div", {
2131
2970
  style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
2132
- children: "没有匹配"
2133
- });
2971
+ children: T("search.empty")
2972
+ }, "empty");
2134
2973
  }
2135
2974
  var out = [];
2136
2975
  for (var i = 0; i < rows.length; i++) {
2137
- out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
2976
+ out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow, extra));
2138
2977
  }
2139
2978
  return out;
2140
2979
  }
@@ -2145,9 +2984,8 @@ window.__ModuleLoader__.load({
2145
2984
  // Implemented as a function (not an inline closure in a loop) so each
2146
2985
  // header captures its own (g, gi) — the var-in-loop closure bug would
2147
2986
  // otherwise make every header jump to the last group.
2148
- function renderGroupHeader(g, gi, jump, C) {
2987
+ function renderGroupHeader(g, gi, jump, C, isActive, headingless) {
2149
2988
  var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
2150
- var targetKey = replyKey || g.userKey;
2151
2989
  var jumpToTurn = function (e) {
2152
2990
  e.stopPropagation();
2153
2991
  // prefer the model's reply; fall back to the user message, then to the
@@ -2158,30 +2996,52 @@ window.__ModuleLoader__.load({
2158
2996
  };
2159
2997
  return react_jsx_runtime.jsx("div", {
2160
2998
  style: {
2161
- padding: "1px 4px 2px",
2162
- height: 18,
2999
+ padding: headingless ? "3px 4px" : "1px 4px 2px",
3000
+ height: headingless ? undefined : 18,
2163
3001
  display: "flex",
2164
3002
  alignItems: "center",
2165
3003
  minWidth: 0,
2166
3004
  // 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).
3005
+ // inside stays pinned at the top. It must be opaque to cover the rows
3006
+ // scrolling underneath, so its base colour is the panel background in both
3007
+ // states — the "current group" tint rides on its own layer inside, which
3008
+ // can FADE (a background-image gradient cannot be transitioned).
2169
3009
  position: "sticky",
2170
3010
  top: 0,
2171
3011
  zIndex: 2,
2172
3012
  background: C.panelBg
2173
3013
  },
2174
- children: react_jsx_runtime.jsx("span", {
3014
+ children: [
3015
+ react_jsx_runtime.jsx("div", {
3016
+ style: {
3017
+ position: "absolute",
3018
+ left: 0,
3019
+ right: 0,
3020
+ top: 0,
3021
+ bottom: 0,
3022
+ background: C.groupTint,
3023
+ opacity: isActive ? 1 : 0,
3024
+ transition: "opacity 0.18s ease",
3025
+ pointerEvents: "none"
3026
+ }
3027
+ }, "g-h-tint-" + gi),
3028
+ react_jsx_runtime.jsx("span", {
2175
3029
  onClick: jumpToTurn,
2176
- title: replyKey ? "跳转到该回合的模型回答开头" : "跳转到该回合开头",
3030
+ title: replyKey ? T("turn.jumpReply") : T("turn.jumpTurn"),
2177
3031
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2178
3032
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
2179
3033
  style: {
2180
- fontSize: 11,
2181
- color: C.muted,
3034
+ // A turn with no headings has ONLY this row, so it carries the whole
3035
+ // entry: readable size, weight and colour. No extra pill background —
3036
+ // the "current group" tint belongs to the group, so headed and
3037
+ // heading-less turns look the same when they are being read.
3038
+ fontSize: headingless ? 12 : 11,
3039
+ fontWeight: headingless ? 600 : 400,
3040
+ color: headingless ? C.text : C.muted,
2182
3041
  cursor: "pointer",
2183
- padding: "1px 5px",
3042
+ padding: headingless ? "2px 6px" : "1px 5px",
2184
3043
  borderRadius: 4,
3044
+ background: "transparent",
2185
3045
  transition: "background 0.15s ease",
2186
3046
  display: "inline-flex",
2187
3047
  alignItems: "center",
@@ -2193,18 +3053,81 @@ window.__ModuleLoader__.load({
2193
3053
  children: [
2194
3054
  react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
2195
3055
  g.userText ? react_jsx_runtime.jsx("span", {
2196
- style: { fontWeight: 400, opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
3056
+ style: {
3057
+ fontWeight: headingless ? 500 : 400,
3058
+ opacity: headingless ? 0.95 : 0.75,
3059
+ overflow: "hidden",
3060
+ textOverflow: "ellipsis",
3061
+ whiteSpace: "nowrap",
3062
+ minWidth: 0
3063
+ },
2197
3064
  children: g.userText
2198
3065
  }) : null
2199
3066
  ]
2200
3067
  })
3068
+ ]
2201
3069
  }, "g-h-" + gi);
2202
3070
  }
2203
3071
 
3072
+ // A turn the paged event window has not loaded yet: the host outline still
3073
+ // names it (number + bounded previews), so the outline covers the whole
3074
+ // session. Clicking pages that turn in through the session's jump loader.
3075
+ function renderGhostGroup(g, gi, C, extra) {
3076
+ extra = extra || {};
3077
+ var children = [
3078
+ react_jsx_runtime.jsx("div", {
3079
+ style: { display: "flex", alignItems: "center", gap: 5, minWidth: 0 },
3080
+ children: [
3081
+ react_jsx_runtime.jsx("span", {
3082
+ style: { fontSize: 10, fontWeight: 600, flex: "none", color: C.muted, opacity: 0.8, border: "1px solid " + C.panelBorder, borderRadius: 4, padding: "0 4px" },
3083
+ children: T("turn.unloaded")
3084
+ }),
3085
+ react_jsx_runtime.jsx("span", {
3086
+ style: { fontSize: 11, color: C.muted, fontWeight: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
3087
+ children: g.userText || "#" + g.turn
3088
+ })
3089
+ ]
3090
+ })
3091
+ ];
3092
+ if (g.preview) {
3093
+ children.push(react_jsx_runtime.jsx("div", {
3094
+ style: { fontSize: 10, lineHeight: "13px", color: C.muted, opacity: 0.65, marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" },
3095
+ children: g.preview
3096
+ }));
3097
+ }
3098
+ return react_jsx_runtime.jsx("div", {
3099
+ onClick: function () { extra.onOpenTurn(g.turn, g.seq); },
3100
+ onMouseEnter: function (e) {
3101
+ e.currentTarget.style.background = C.hover;
3102
+ e.currentTarget.style.opacity = "1";
3103
+ extra.hoverStart({ title: g.userText || "#" + g.turn, sub: "", preview: g.preview || "", meta: T("turn.unloaded"), ghost: true }, e.currentTarget);
3104
+ },
3105
+ onMouseLeave: function (e) {
3106
+ e.currentTarget.style.background = "transparent";
3107
+ e.currentTarget.style.opacity = "0.85";
3108
+ extra.hoverEnd();
3109
+ },
3110
+ "data-group-idx": gi,
3111
+ "data-ghost-turn": g.turn,
3112
+ title: T("turn.loadTip"),
3113
+ style: {
3114
+ padding: "3px 6px",
3115
+ margin: "1px 0",
3116
+ borderRadius: 6,
3117
+ cursor: "pointer",
3118
+ minWidth: 0,
3119
+ opacity: 0.85,
3120
+ transition: "background 0.15s ease, opacity 0.15s ease"
3121
+ },
3122
+ children: children
3123
+ }, "g-ghost-" + gi);
3124
+ }
3125
+
2204
3126
  // render each conversation turn as its own block: solid divider + time header
2205
3127
  // offset = global group index of the first rendered group (stable React keys)
2206
3128
  // activeIdx = array of groups being read (full opacity); others are dimmed
2207
- function renderGroups(groups, trees, jump, C, offset, activeIdx) {
3129
+ function renderGroups(groups, trees, jump, C, offset, activeIdx, extra) {
3130
+ extra = extra || {};
2208
3131
  var base = offset || 0;
2209
3132
  var activeSet = {};
2210
3133
  if (Array.isArray(activeIdx)) {
@@ -2214,24 +3137,55 @@ window.__ModuleLoader__.load({
2214
3137
  for (var i = 0; i < groups.length; i++) {
2215
3138
  var g = groups[i];
2216
3139
  var gi = base + i;
2217
- var items = [];
2218
- items.push(react_jsx_runtime.jsx("div", {
3140
+ var isActive = !!activeSet[gi];
3141
+ var rows = g.ghost ? [] : trees[i];
3142
+ // The divider between two groups stays OUTSIDE the highlight box: inside it
3143
+ // would draw a rule straight across the tinted block.
3144
+ out.push(react_jsx_runtime.jsx("div", {
2219
3145
  style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
2220
3146
  }, "g-sep-" + gi));
2221
- items.push(renderGroupHeader(g, gi, jump, C));
2222
- items.push(renderNodes(trees[i], 0, jump, C, "g" + gi));
2223
- var dim = !activeSet[gi];
3147
+ var items = [];
3148
+ if (g.ghost) {
3149
+ items.push(renderGhostGroup(g, gi, C, extra));
3150
+ } else {
3151
+ items.push(renderGroupHeader(g, gi, jump, C, isActive, !rows || rows.length === 0));
3152
+ items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
3153
+ }
3154
+ // Inactive groups used to fade to 0.6, which — multiplied by the panel's own
3155
+ // idle opacity — turned the whole list into grey. 0.85 keeps every row
3156
+ // readable, and "the group being read" is marked by a CLOSED tinted box
3157
+ // (fill + outline + accent bar) instead, which stays visible on top of any
3158
+ // panel transparency and no longer looks cut off on the right.
3159
+ var dim = !isActive && !g.ghost;
2224
3160
  out.push(react_jsx_runtime.jsx("div", {
2225
3161
  "data-group-idx": gi,
2226
- style: { opacity: dim ? 0.6 : 1, transition: "opacity 0.3s ease" },
3162
+ "data-active-group": isActive ? "on" : "off",
3163
+ style: {
3164
+ opacity: dim ? 0.85 : 1,
3165
+ // the box fades in AND out: colour transitions only, and the left accent
3166
+ // keeps its 3px width in both states so activating a group never reflows
3167
+ // (a width change would shift every row by 2px)
3168
+ transition: "opacity 0.15s ease, background-color 0.18s ease, border-color 0.18s ease",
3169
+ backgroundColor: isActive ? C.groupTint : "transparent",
3170
+ borderTop: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
3171
+ borderRight: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
3172
+ borderBottom: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
3173
+ borderLeft: "3px solid " + (isActive ? C.groupEdge : "transparent"),
3174
+ borderRadius: 6,
3175
+ margin: "2px -5px",
3176
+ paddingLeft: 2
3177
+ },
2227
3178
  children: items
2228
3179
  }, "g-" + gi));
2229
3180
  }
2230
3181
  return out;
2231
3182
  }
2232
3183
 
3184
+ // Host-facing locale namespace: the slot declares `locale: "dsh-quick-toc"`,
3185
+ // which is what hands the panel its `t` prop. The panel's own visible strings
3186
+ // live in DICTS and are kept in sync with this table.
2233
3187
  var zh = {
2234
- "panel.title": "对话大纲"
3188
+ "panel.title": DICTS.zh["panel.title"]
2235
3189
  };
2236
3190
  var en = {
2237
3191
  "panel.title": "Conversation Outline"
@@ -2243,6 +3197,29 @@ window.__ModuleLoader__.load({
2243
3197
  ctx.effect(function () {
2244
3198
  return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
2245
3199
  }, "dsh-quick-toc: dictionaries");
3200
+
3201
+ // Bridge to the host session face, used to open a turn whose events the
3202
+ // paged window has not loaded: ctx.sessions.binding(id).session.loadThrough(seq)
3203
+ // is the host's documented "page history backwards until the window covers
3204
+ // seq" jump loader.
3205
+ //
3206
+ // The client plugin facade offers two ways in: a direct `ctx.sessions`
3207
+ // property read (which demands `inject: ["sessions"]` on the returned plugin
3208
+ // object, and the runtime then PARKS the whole package whenever that provider
3209
+ // is absent) and `ctx.get(name)`, the declaration-free lookup. This uses the
3210
+ // lookup — deliberately optional, so a host without the session controller
3211
+ // still gets a working outline, and callers report the missing loader
3212
+ // instead of the panel disappearing.
3213
+ var host = {
3214
+ sessions: function () {
3215
+ try {
3216
+ return typeof ctx.get === "function" ? (ctx.get("sessions") || null) : null;
3217
+ } catch (e) {
3218
+ return null;
3219
+ }
3220
+ }
3221
+ };
3222
+
2246
3223
  // Recent DSH (0.1.5-rc.1): session-scoped hooks (useChat/useSession/sessionId) only arrive
2247
3224
  // inside a declared session slot. Register the panel into the session-scoped
2248
3225
  // conversation.input.overlay (list/additive) so it receives useChat; the panel
@@ -2254,7 +3231,10 @@ window.__ModuleLoader__.load({
2254
3231
  id: "quick-toc",
2255
3232
  order: 90,
2256
3233
  locale: "dsh-quick-toc"
2257
- }, OutlinePanel);
3234
+ }, function OutlinePanelWithHost(props) {
3235
+ // the panel needs the host bridge plus the session id it was registered for
3236
+ return OutlinePanel(Object.assign({}, props, { tocHost: host }));
3237
+ });
2258
3238
  });
2259
3239
  }
2260
3240
 
@@ -2269,6 +3249,21 @@ window.__ModuleLoader__.load({
2269
3249
  var s = document.createElement("style");
2270
3250
  s.id = "dsh-quick-toc-css";
2271
3251
  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}}" +
3252
+ "@keyframes dqt-pop-in{from{opacity:0;transform:scale(0.7)}to{opacity:1;transform:scale(1)}}@keyframes dqt-pop-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(0.7)}}" +
3253
+ // the level picker's enter/exit animations live here as classes so React
3254
+ // re-renders (which rewrite inline styles) cannot restart or cancel them
3255
+ ".dqt-levels-pop{animation:dqt-pop-in 0.22s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
3256
+ // hover preview card: fades in place, and fades OUT when the pointer
3257
+ // leaves (both as stylesheet classes so re-renders cannot cancel them)
3258
+ ".dqt-hover{animation:dqt-fade-in 0.12s linear both}" +
3259
+ ".dqt-hover-closing{animation:dqt-fade-out 0.16s cubic-bezier(0.2, 0.9, 0.3, 1) both}" +
3260
+ // transient edge banner over the bottom of the outline list
3261
+ "@keyframes dqt-toast-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}" +
3262
+ "@keyframes dqt-toast-out{from{opacity:1;transform:none}to{opacity:0;transform:none}}" +
3263
+ ".dqt-toast{animation:dqt-toast-in 0.2s cubic-bezier(0.22, 0.9, 0.3, 1) both}" +
3264
+ ".dqt-toast-closing{animation:dqt-toast-out 0.42s linear both}" +
3265
+ // exit: starts moving immediately (fast attack), so the dismiss feels snappy
3266
+ ".dqt-levels-pop-closing{animation:dqt-pop-out 0.14s cubic-bezier(0.2, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
2272
3267
  // Hide the native scrollbar entirely (no arrow buttons, no grey bar) —
2273
3268
  // custom webkit scrollbar styling was not reliably suppressing the
2274
3269
  // default arrows, so a hidden native bar + the panel's inner shadow gives