dsh-quick-toc 0.4.1 → 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);
@@ -663,6 +949,79 @@ window.__ModuleLoader__.load({
663
949
  var listRef = react.useRef(null);
664
950
  var didInitScroll = react.useRef(false);
665
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
+ };
666
1025
 
667
1026
  // ---- the outline belongs to the CHAT view only: when the center column
668
1027
  // switches to another view (trajectory / context / plugin views), fade the
@@ -734,6 +1093,21 @@ window.__ModuleLoader__.load({
734
1093
  if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
735
1094
  scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
736
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
+ };
737
1111
  var openSearch = function () {
738
1112
  setSearchOpen(true);
739
1113
  setSearchAnim("enter");
@@ -789,9 +1163,9 @@ window.__ModuleLoader__.load({
789
1163
  }
790
1164
  return false;
791
1165
  };
792
- if (grow()) return;
793
- var btn = findLoadOlderButton();
794
- if (btn && !btn.disabled) {
1166
+ var viaHost = function () {
1167
+ var btn = findLoadOlderButton();
1168
+ if (!btn || btn.disabled) return false;
795
1169
  btn.click();
796
1170
  // once the conversation loads more, expand the outline window too
797
1171
  setTimeout(function () {
@@ -804,7 +1178,34 @@ window.__ModuleLoader__.load({
804
1178
  });
805
1179
  }
806
1180
  }, 1200);
807
- }
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();
808
1209
  };
809
1210
 
810
1211
  // wheel up (toward older) loads more when the list is at its top or has
@@ -812,17 +1213,52 @@ window.__ModuleLoader__.load({
812
1213
  // always refreshes older turns, even without a visible scrollbar
813
1214
  var onListWheel = function (e) {
814
1215
  outlineTouchRef.current = Date.now();
815
- if (e.deltaY >= 0) return;
816
1216
  var el = listRef.current;
817
1217
  if (!el) return;
818
- 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
+ }
819
1237
  };
820
1238
 
821
1239
  // scroll to the top edge also loads older groups (keeps the visual position)
822
1240
  var onListScroll = function (e) {
823
1241
  outlineTouchRef.current = Date.now();
824
1242
  var el = e.currentTarget;
825
- 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
+ }
826
1262
  };
827
1263
 
828
1264
  // ---- resize drags (right edge = width, bottom edge = height) ----
@@ -977,14 +1413,53 @@ window.__ModuleLoader__.load({
977
1413
  var info = nodeInfo(key, node);
978
1414
  current.msgs.push({ key: key, text: info.text });
979
1415
  for (var k = 0; k < info.headings.length; k++) {
980
- 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
+ });
981
1424
  }
982
1425
  }
983
1426
  pruneNodeInfo(seen);
984
1427
  // keep a turn when it has headings OR a time — turns without headings
985
1428
  // still get a standalone time entry in the outline (click to jump)
986
- return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
987
- }, [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]);
988
1463
  // first time content appears, scroll the list to the bottom (newest).
989
1464
  // NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
990
1465
  // during render, so reading `groups` before that `var` is assigned would
@@ -994,14 +1469,25 @@ window.__ModuleLoader__.load({
994
1469
  didInitScroll.current = true;
995
1470
  listRef.current.scrollTop = listRef.current.scrollHeight;
996
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
+
997
1483
  var groupTrees = react.useMemo(function () {
998
1484
  return groups.map(function (g) {
999
- if (levels.length === 6) return buildTree(g.headings);
1485
+ if (levels.length === 6) return buildTree(g.headings, g.time);
1000
1486
  var kept = [];
1001
1487
  for (var i = 0; i < g.headings.length; i++) {
1002
1488
  if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
1003
1489
  }
1004
- return buildTree(kept);
1490
+ return buildTree(kept, g.time);
1005
1491
  });
1006
1492
  }, [groups, levels, levelSet]);
1007
1493
  // pagination slice: the latest `visibleCount` groups
@@ -1009,12 +1495,22 @@ window.__ModuleLoader__.load({
1009
1495
  var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
1010
1496
 
1011
1497
  // the group currently being read (the turn under the middle of the
1012
- // 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.
1013
1503
  var keyToGroup = react.useMemo(function () {
1014
1504
  var m = {};
1015
1505
  for (var i = 0; i < groups.length; i++) {
1016
- for (var j = 0; j < groups[i].headings.length; j++) {
1017
- 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;
1018
1514
  }
1019
1515
  }
1020
1516
  return m;
@@ -1045,8 +1541,9 @@ window.__ModuleLoader__.load({
1045
1541
  // search result rows: one row per matched heading/message (with an
1046
1542
  // occurrence count), carrying its heading path, turn time and a snippet.
1047
1543
  var resultRows = react.useMemo(function () {
1048
- var q = query.trim().toLowerCase();
1049
- if (!q) return [];
1544
+ var fq = foldQuery(query);
1545
+ if (!fq) return [];
1546
+ var needle = { folded: fq }; // one folded query, reused for every text
1050
1547
  var rows = [];
1051
1548
  var index = {};
1052
1549
  var push = function (row, n) {
@@ -1059,43 +1556,83 @@ window.__ModuleLoader__.load({
1059
1556
  };
1060
1557
  for (var gi = 0; gi < groups.length; gi++) {
1061
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
+ }
1062
1577
  for (var j = 0; j < g.headings.length; j++) {
1063
1578
  var h = g.headings[j];
1064
- var nh = countOccurrences(h.title, q);
1579
+ var nh = countOccurrences(h.title, needle, fuzzy);
1065
1580
  if (nh > 0) {
1066
1581
  var hp = headingPaths[headingId(h)];
1067
1582
  push({
1068
1583
  gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
1069
- 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 || ""
1070
1586
  }, nh);
1071
1587
  }
1072
1588
  }
1073
1589
  if (searchScope === "full") {
1074
- var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, q) : 0;
1590
+ var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, needle, fuzzy) : 0;
1075
1591
  if (nu > 0) {
1076
1592
  push({
1077
1593
  gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
1078
- 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)
1079
1595
  }, nu);
1080
1596
  }
1081
1597
  for (var m = 0; m < g.msgs.length; m++) {
1082
1598
  var msg = g.msgs[m];
1083
1599
  if (!msg.text) continue;
1084
- var nm = countOccurrences(msg.text, q);
1600
+ var nm = countOccurrences(msg.text, needle, fuzzy);
1085
1601
  if (nm === 0) continue;
1086
1602
  push({
1087
1603
  gi: gi, key: msg.key, idx: undefined, title: previewText(msg.text, 40),
1088
- 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)
1089
1605
  }, nm);
1090
1606
  }
1091
1607
  }
1092
1608
  }
1093
1609
  return rows;
1094
- }, [groups, query, searchScope, 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.
1095
1622
  react.useEffect(function () {
1096
- var sp = document.querySelector("[data-conversation-scroll]");
1097
- if (!sp) return;
1098
- 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;
1099
1636
  var lr = sp.getBoundingClientRect();
1100
1637
  var vTop = lr.top;
1101
1638
  var vBottom = lr.top + lr.height;
@@ -1111,37 +1648,43 @@ window.__ModuleLoader__.load({
1111
1648
  }
1112
1649
  }
1113
1650
  var sig = actives.slice().sort().join(",");
1114
- if (sig !== activeSigRef.current) {
1115
- activeSigRef.current = sig;
1116
- setActiveGroup(actives);
1117
- // auto-follow: keep the reading position visible in the outline —
1118
- // but pause for ~2s after the user touches the outline themselves,
1119
- // otherwise loading older turns gets yanked back to the bottom
1120
- if (actives.length > 0 && Date.now() - outlineTouchRef.current > 2000) {
1121
- var gi0 = actives[0];
1122
- // ensure the group's window is loaded (with a small buffer below)
1123
- setVisibleCount(function (prev) {
1124
- var need = groups.length - gi0 + 3;
1125
- return Math.max(prev, Math.min(groups.length, need));
1126
- });
1127
- var el = listRef.current;
1128
- if (el) {
1129
- setTimeout(function () {
1130
- var node = el.querySelector('[data-group-idx="' + gi0 + '"]');
1131
- if (!node) return;
1132
- var er = node.getBoundingClientRect();
1133
- var lr2 = el.getBoundingClientRect();
1134
- if (er.top < lr2.top - 2 || er.bottom > lr2.bottom + 2) {
1135
- el.scrollTo({ top: el.scrollTop + (er.top - lr2.top) - el.clientHeight / 2 + node.offsetHeight / 2, behavior: "smooth" });
1136
- }
1137
- }, 120);
1138
- }
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;
1139
1674
  }
1140
- }
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);
1141
1687
  };
1142
- update();
1143
- sp.addEventListener("scroll", update, { passive: true });
1144
- return function () { sp.removeEventListener("scroll", update); };
1145
1688
  }, [keyToGroup]);
1146
1689
 
1147
1690
  // ---- search matches ----
@@ -1150,21 +1693,32 @@ window.__ModuleLoader__.load({
1150
1693
  // Every occurrence counts (multiple hits inside one message = multiple
1151
1694
  // matches), so the n/N counter reflects the real total.
1152
1695
  var matches = react.useMemo(function () {
1153
- var q = query.trim().toLowerCase();
1154
- if (!q) return [];
1696
+ var fq = foldQuery(query);
1697
+ if (!fq) return [];
1698
+ var needle = { folded: fq };
1155
1699
  var out = [];
1156
1700
  for (var gi = 0; gi < groups.length; gi++) {
1157
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
+ }
1158
1712
  for (var j = 0; j < g.headings.length; j++) {
1159
1713
  var h = g.headings[j];
1160
- var n = countOccurrences(h.title, q);
1714
+ var n = countOccurrences(h.title, needle, fuzzy);
1161
1715
  for (var c = 0; c < n; c++) {
1162
1716
  out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
1163
1717
  }
1164
1718
  }
1165
1719
  if (searchScope === "full") {
1166
1720
  if (g.userKey && g.userFull) {
1167
- var nu = countOccurrences(g.userFull, q);
1721
+ var nu = countOccurrences(g.userFull, needle, fuzzy);
1168
1722
  for (var cu = 0; cu < nu; cu++) {
1169
1723
  out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
1170
1724
  }
@@ -1172,7 +1726,7 @@ window.__ModuleLoader__.load({
1172
1726
  for (var m = 0; m < g.msgs.length; m++) {
1173
1727
  var msg = g.msgs[m];
1174
1728
  if (!msg.text) continue;
1175
- var nm = countOccurrences(msg.text, q);
1729
+ var nm = countOccurrences(msg.text, needle, fuzzy);
1176
1730
  for (var cm = 0; cm < nm; cm++) {
1177
1731
  out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
1178
1732
  }
@@ -1180,7 +1734,7 @@ window.__ModuleLoader__.load({
1180
1734
  }
1181
1735
  }
1182
1736
  return out;
1183
- }, [groups, query, searchScope]);
1737
+ }, [groups, query, searchScope, fuzzy]);
1184
1738
 
1185
1739
  // which result row holds the current (n/N) occurrence (needs `matches`)
1186
1740
  var activeResultRow = -1;
@@ -1203,10 +1757,13 @@ window.__ModuleLoader__.load({
1203
1757
  var q = query.trim();
1204
1758
  if (searchPosRef.current === q) return;
1205
1759
  searchPosRef.current = q;
1206
- if (!q) return;
1760
+ if (!q) { setHint(""); return; }
1207
1761
  var el = listRef.current;
1208
1762
  if (el) el.scrollTop = el.scrollHeight;
1209
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" : "");
1210
1767
  }, [query, matches.length]);
1211
1768
 
1212
1769
  // keep the current row in view while stepping with Enter
@@ -1277,15 +1834,8 @@ window.__ModuleLoader__.load({
1277
1834
  // ---- jump: smooth glide to the exact heading element. When glued to the
1278
1835
  // bottom, lift just past DSH's 25px stick-to-bottom threshold first so
1279
1836
  // the glide is not yanked back. ----
1280
- var jump = function (key, idx) {
1281
- var row = findRow(key);
1282
- if (!row) return;
1283
- var el = row;
1284
- if (idx !== undefined && idx !== null) {
1285
- var hs = row.querySelectorAll("h1, h2, h3, h4, h5, h6");
1286
- if (hs.length > 0) el = hs[Math.min(idx, hs.length - 1)] || row;
1287
- }
1288
- 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;
1289
1839
  if (!sp) {
1290
1840
  el.scrollIntoView({ behavior: "smooth", block: "start" });
1291
1841
  return;
@@ -1298,6 +1848,104 @@ window.__ModuleLoader__.load({
1298
1848
  sp.scrollTo({ top: t, behavior: "smooth" });
1299
1849
  };
1300
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
+
1301
1949
  // jump to the n-th match, cycling; also reveal the group in the outline.
1302
1950
  // ONE smooth scroll straight to the current occurrence (no competing
1303
1951
  // scrolls): highlight first, then position the mark at the upper-middle
@@ -1306,14 +1954,21 @@ window.__ModuleLoader__.load({
1306
1954
  var i = ((n % matches.length) + matches.length) % matches.length;
1307
1955
  setMatchIdx(i);
1308
1956
  var m = matches[i];
1309
- var q = query.trim().toLowerCase();
1957
+ var q = query.trim();
1310
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
+ }
1311
1966
  var r = findRowStrict(m.key);
1312
1967
  if (r && q) {
1313
1968
  // occurrence index within the target message (consecutive in matches)
1314
1969
  var occ = 0;
1315
1970
  for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
1316
- highlightRow(r, q, occ);
1971
+ highlightRow(r, q, occ, fuzzy);
1317
1972
  var markEl = r.querySelector(".dqt-current");
1318
1973
  var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
1319
1974
  if (sp && markEl) {
@@ -1330,15 +1985,7 @@ window.__ModuleLoader__.load({
1330
1985
  } else {
1331
1986
  jump(m.key, m.idx);
1332
1987
  }
1333
- setVisibleCount(function (prev) {
1334
- return Math.max(prev, Math.min(groups.length, groups.length - m.gi));
1335
- });
1336
- setTimeout(function () {
1337
- var el = listRef.current;
1338
- if (!el) return;
1339
- var node = el.querySelector('[data-group-idx="' + m.gi + '"]');
1340
- if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
1341
- }, 150);
1988
+ revealGroupInOutline(m.gi);
1342
1989
  };
1343
1990
 
1344
1991
  // clicking result row `i` = make its FIRST occurrence the current match, so
@@ -1395,6 +2042,47 @@ window.__ModuleLoader__.load({
1395
2042
  };
1396
2043
 
1397
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;
1398
2086
  // panel: opening slides in with a slow fade; closing slides quickly to
1399
2087
  // the dock edge, clipped by the sidebar line (looks covered, not
1400
2088
  // dissolving) and only fades at the very end. No box-shadow: a shadow
@@ -1419,7 +2107,12 @@ window.__ModuleLoader__.load({
1419
2107
  }
1420
2108
  }
1421
2109
  // faded out entirely while another center-column view is active
1422
- 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;
1423
2116
 
1424
2117
  var panelEl = react_jsx_runtime.jsx("div", {
1425
2118
  style: {
@@ -1469,7 +2162,7 @@ window.__ModuleLoader__.load({
1469
2162
  cursor: "grab",
1470
2163
  userSelect: "none"
1471
2164
  },
1472
- title: "按住拖动调整位置",
2165
+ title: T("handle.dragY"),
1473
2166
  onPointerDown: onHandleDown,
1474
2167
  onMouseEnter: function () { handleBright(true); },
1475
2168
  onMouseLeave: function () { handleBright(false); },
@@ -1502,7 +2195,7 @@ window.__ModuleLoader__.load({
1502
2195
  // three-bar outline mark (long / medium / short) — no title text
1503
2196
  react_jsx_runtime.jsx("div", {
1504
2197
  style: { display: "flex", alignItems: "center", color: C.muted, flex: "none" },
1505
- title: "对话大纲",
2198
+ title: T("panel.title"),
1506
2199
  children: react_jsx_runtime.jsx("svg", {
1507
2200
  width: 18,
1508
2201
  height: 14,
@@ -1526,7 +2219,7 @@ window.__ModuleLoader__.load({
1526
2219
  react_jsx_runtime.jsx("button", {
1527
2220
  className: "dqt-levels-btn",
1528
2221
  onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
1529
- title: "标题层级筛选",
2222
+ title: T("levels.tip"),
1530
2223
  style: {
1531
2224
  width: 24,
1532
2225
  height: 24,
@@ -1539,8 +2232,10 @@ window.__ModuleLoader__.load({
1539
2232
  display: "flex",
1540
2233
  alignItems: "center",
1541
2234
  justifyContent: "center",
1542
- background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
1543
- color: C.muted,
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,
1544
2239
  transition: "background 0.15s ease, color 0.15s ease"
1545
2240
  },
1546
2241
  onMouseEnter: function (e) {
@@ -1548,8 +2243,8 @@ window.__ModuleLoader__.load({
1548
2243
  e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
1549
2244
  },
1550
2245
  onMouseLeave: function (e) {
1551
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
1552
- e.currentTarget.style.color = C.muted;
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;
1553
2248
  },
1554
2249
  children: react_jsx_runtime.jsx("svg", {
1555
2250
  width: 14,
@@ -1570,7 +2265,7 @@ window.__ModuleLoader__.load({
1570
2265
  // magnifier button (SVG, matches the other buttons' style)
1571
2266
  react_jsx_runtime.jsx("button", {
1572
2267
  onClick: function () { searchOpen ? closeSearch() : openSearch(); },
1573
- title: "搜索标题",
2268
+ title: T("search.open"),
1574
2269
  style: {
1575
2270
  width: 24,
1576
2271
  height: 24,
@@ -1579,9 +2274,9 @@ window.__ModuleLoader__.load({
1579
2274
  justifyContent: "center",
1580
2275
  borderRadius: "50%",
1581
2276
  cornerShape: "round",
1582
- 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))",
1583
2278
  border: "none",
1584
- color: C.muted,
2279
+ color: searchOpen ? C.accent : C.muted,
1585
2280
  cursor: "pointer"
1586
2281
  },
1587
2282
  onMouseEnter: function (e) {
@@ -1589,8 +2284,8 @@ window.__ModuleLoader__.load({
1589
2284
  e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
1590
2285
  },
1591
2286
  onMouseLeave: function (e) {
1592
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
1593
- 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;
1594
2289
  },
1595
2290
  children: react_jsx_runtime.jsx("svg", {
1596
2291
  width: 15,
@@ -1610,11 +2305,11 @@ window.__ModuleLoader__.load({
1610
2305
  })
1611
2306
  }),
1612
2307
  // triangle tips toward the side it will move TO: shift slightly up + toward the tip
1613
- 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 }),
1614
2309
  // close: thick SVG cross, nudged slightly down
1615
2310
  react_jsx_runtime.jsx("button", {
1616
2311
  onClick: function () { setOpen(false); },
1617
- title: "收起",
2312
+ title: T("handle.collapse"),
1618
2313
  style: {
1619
2314
  width: 24,
1620
2315
  height: 24,
@@ -1726,23 +2421,23 @@ window.__ModuleLoader__.load({
1726
2421
  color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
1727
2422
  },
1728
2423
  children: [
1729
- "搜索",
2424
+ T("search.word"),
1730
2425
  react_jsx_runtime.jsx("span", {
1731
2426
  style: { position: "relative", display: "inline-block" },
1732
2427
  children: [
1733
2428
  react_jsx_runtime.jsx("span", {
1734
2429
  key: searchScope,
1735
2430
  style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
1736
- children: searchScope === "title" ? "标题" : "全文"
2431
+ children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
1737
2432
  }),
1738
2433
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1739
2434
  key: "old-" + prevScope,
1740
2435
  style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
1741
- children: prevScope === "title" ? "标题" : "全文"
2436
+ children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
1742
2437
  }) : null
1743
2438
  ]
1744
2439
  }),
1745
- ",回车定位…"
2440
+ T("search.tail")
1746
2441
  ]
1747
2442
  }) : null,
1748
2443
  ]
@@ -1771,7 +2466,7 @@ window.__ModuleLoader__.load({
1771
2466
  // label text cross-fades old->new)
1772
2467
  react_jsx_runtime.jsx("button", {
1773
2468
  onClick: toggleScope,
1774
- title: searchScope === "title" ? "当前:仅搜索标题。点击切换为全文搜索" : "当前:全文搜索。点击切换为仅标题",
2469
+ title: searchScope === "title" ? T("search.scope.tipTitle") : T("search.scope.tipFull"),
1775
2470
  style: {
1776
2471
  flex: "none",
1777
2472
  width: 34,
@@ -1783,7 +2478,7 @@ window.__ModuleLoader__.load({
1783
2478
  cornerShape: "round",
1784
2479
  fontSize: 11,
1785
2480
  color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1786
- 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))",
1787
2482
  border: "none",
1788
2483
  cursor: "pointer",
1789
2484
  whiteSpace: "nowrap",
@@ -1795,15 +2490,40 @@ window.__ModuleLoader__.load({
1795
2490
  react_jsx_runtime.jsx("span", {
1796
2491
  key: searchScope,
1797
2492
  style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
1798
- children: searchScope === "title" ? "标题" : "全文"
2493
+ children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
1799
2494
  }),
1800
2495
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1801
2496
  key: "old-" + prevScope,
1802
2497
  style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
1803
- children: prevScope === "title" ? "标题" : "全文"
2498
+ children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
1804
2499
  }) : null
1805
2500
  ]
1806
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")
1807
2527
  })
1808
2528
  ]
1809
2529
  })
@@ -1830,10 +2550,32 @@ window.__ModuleLoader__.load({
1830
2550
  children: react_jsx_runtime.jsx("div", {
1831
2551
  style: { direction: "ltr" },
1832
2552
  children: query.trim()
1833
- ? renderResults(resultRows, query.trim().toLowerCase(), C, goToRow, activeResultRow)
1834
- : 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)
1835
2576
  })
1836
2577
  }),
2578
+ toastEl,
1837
2579
  // resize handles (right edge: width, bottom edge: height)
1838
2580
  react_jsx_runtime.jsx("div", {
1839
2581
  style: {
@@ -1847,7 +2589,7 @@ window.__ModuleLoader__.load({
1847
2589
  zIndex: 2
1848
2590
  },
1849
2591
  onPointerDown: onResizeWDown,
1850
- title: "拖拽调整宽度"
2592
+ title: T("resize.w")
1851
2593
  }),
1852
2594
  react_jsx_runtime.jsx("div", {
1853
2595
  style: {
@@ -1861,7 +2603,7 @@ window.__ModuleLoader__.load({
1861
2603
  zIndex: 2
1862
2604
  },
1863
2605
  onPointerDown: onResizeHDown,
1864
- title: "拖拽调整高度"
2606
+ title: T("resize.h")
1865
2607
  }),
1866
2608
  // corner handle: resize width AND height at once
1867
2609
  react_jsx_runtime.jsx("div", {
@@ -1876,7 +2618,7 @@ window.__ModuleLoader__.load({
1876
2618
  zIndex: 3
1877
2619
  },
1878
2620
  onPointerDown: onResizeCornerDown,
1879
- title: "拖拽同时调整宽高"
2621
+ title: T("resize.wh")
1880
2622
  }),
1881
2623
  // heading-level picker: pops down from the round header button,
1882
2624
  // right-aligned, with an outer shadow so it reads as a floating layer.
@@ -1908,7 +2650,7 @@ window.__ModuleLoader__.load({
1908
2650
  var active = !!levelSet[lv];
1909
2651
  return react_jsx_runtime.jsx("button", {
1910
2652
  onClick: function () { toggleLevel(lv); },
1911
- title: active ? "隐藏 H" + lv : "显示 H" + lv,
2653
+ title: active ? T("levels.hide") + lv : T("levels.show") + lv,
1912
2654
  style: {
1913
2655
  height: 22,
1914
2656
  padding: "0 7px",
@@ -1920,9 +2662,9 @@ window.__ModuleLoader__.load({
1920
2662
  lineHeight: "22px",
1921
2663
  fontFamily: "inherit",
1922
2664
  background: active
1923
- ? "rgba(79,140,255,0.18)"
2665
+ ? C.chip
1924
2666
  : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1925
- color: active ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
2667
+ color: active ? C.accent : C.muted,
1926
2668
  fontWeight: active ? 600 : 400
1927
2669
  },
1928
2670
  children: "H" + lv
@@ -1952,7 +2694,7 @@ window.__ModuleLoader__.load({
1952
2694
  ? { right: viewport ? viewport.right + 52 : 60 } // clear of the milestone rail
1953
2695
  : { left: viewport ? viewport.left : 0 })
1954
2696
  },
1955
- title: "展开大纲",
2697
+ title: T("handle.expand"),
1956
2698
  onClick: function () { setOpen(true); },
1957
2699
  onMouseEnter: function () { if (edgeRef.current) edgeRef.current.style.opacity = "1"; },
1958
2700
  onMouseLeave: function () { if (edgeRef.current) edgeRef.current.style.opacity = "0.5"; },
@@ -1987,29 +2729,119 @@ window.__ModuleLoader__.load({
1987
2729
  })
1988
2730
  }) : null;
1989
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
+
1990
2787
  return react_dom.createPortal(
1991
- react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl] }),
2788
+ react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl, hoverEl] }),
1992
2789
  document.body
1993
2790
  );
1994
2791
  }
1995
2792
 
1996
- function renderItem(n, depth, jump, C, uid) {
2793
+ function renderItem(n, depth, jump, C, uid, extra) {
1997
2794
  var hasChildren = !!(n.children && n.children.length);
1998
- var parts = [];
1999
- parts.push(react_jsx_runtime.jsx("span", {
2000
- style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
2001
- children: n.title
2002
- }, 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
+ }
2003
2828
  return react_jsx_runtime.jsx(
2004
2829
  "div",
2005
2830
  {
2006
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
+ },
2007
2840
  "data-jump-key": n.key,
2008
2841
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
2009
2842
  style: {
2010
- display: "flex",
2011
- alignItems: "center",
2012
- gap: 2,
2843
+ // no fixed height: a row grows by one line when it has a subtitle
2844
+ display: "block",
2013
2845
  padding: "2px 6px",
2014
2846
  paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
2015
2847
  margin: "1px 0",
@@ -2019,26 +2851,23 @@ window.__ModuleLoader__.load({
2019
2851
  color: n.level <= 2 ? C.text : C.muted,
2020
2852
  fontWeight: n.level <= 2 ? 600 : 400,
2021
2853
  lineHeight: "18px",
2022
- height: 22,
2023
2854
  whiteSpace: "nowrap",
2024
2855
  overflow: "hidden"
2025
2856
  },
2026
- onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2027
- onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
2028
2857
  title: n.title,
2029
- children: parts
2858
+ children: rows
2030
2859
  },
2031
2860
  uid + "-" + n.level + "-" + (n.key || "")
2032
2861
  );
2033
2862
  }
2034
2863
 
2035
- function renderNodes(nodes, depth, jump, C, uid) {
2864
+ function renderNodes(nodes, depth, jump, C, uid, extra) {
2036
2865
  var out = [];
2037
2866
  for (var i = 0; i < nodes.length; i++) {
2038
2867
  var n = nodes[i];
2039
- out.push(renderItem(n, depth, jump, C, uid + "-" + i));
2868
+ out.push(renderItem(n, depth, jump, C, uid + "-" + i, extra));
2040
2869
  if (n.children && n.children.length) {
2041
- 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));
2042
2871
  }
2043
2872
  }
2044
2873
  return out;
@@ -2048,8 +2877,8 @@ window.__ModuleLoader__.load({
2048
2877
  // While a query is present the list shows every matched heading/message
2049
2878
  // (grouped per message, with an occurrence count) instead of the outline, so
2050
2879
  // search is "see them all, then jump" rather than stepping blindly.
2051
- function hitSpans(text, q) {
2052
- var parts = highlightParts(text, q);
2880
+ function hitSpans(text, q, fuzzy) {
2881
+ var parts = highlightParts(text, q, fuzzy);
2053
2882
  return parts.map(function (p, i) {
2054
2883
  return p.hit
2055
2884
  ? react_jsx_runtime.jsx("span", {
@@ -2060,8 +2889,10 @@ window.__ModuleLoader__.load({
2060
2889
  });
2061
2890
  }
2062
2891
 
2063
- function renderResultRow(r, i, q, C, onRow, isActive) {
2892
+ function renderResultRow(r, i, q, C, onRow, isActive, extra) {
2893
+ var fuzzy = !!(extra && extra.fuzzy);
2064
2894
  var meta = [];
2895
+ if (r.ghost) meta.push(T("turn.unloaded"));
2065
2896
  if (r.time) meta.push(r.time);
2066
2897
  if (r.path) meta.push(r.path);
2067
2898
  if (r.count > 1) meta.push("×" + r.count);
@@ -2075,7 +2906,7 @@ window.__ModuleLoader__.load({
2075
2906
  overflow: "hidden",
2076
2907
  textOverflow: "ellipsis"
2077
2908
  },
2078
- children: hitSpans(r.title, q)
2909
+ children: hitSpans(r.title, q, fuzzy)
2079
2910
  }, "t" + i)
2080
2911
  ];
2081
2912
  if (r.snippet) {
@@ -2088,7 +2919,7 @@ window.__ModuleLoader__.load({
2088
2919
  overflow: "hidden",
2089
2920
  textOverflow: "ellipsis"
2090
2921
  },
2091
- children: hitSpans(r.snippet, q)
2922
+ children: hitSpans(r.snippet, q, fuzzy)
2092
2923
  }, "s" + i));
2093
2924
  }
2094
2925
  if (meta.length) {
@@ -2102,9 +2933,23 @@ window.__ModuleLoader__.load({
2102
2933
  // keyword in the conversation and scrolls there, exactly like Enter
2103
2934
  // stepping — a row that only scrolled (no highlight) was the old bug
2104
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
+ },
2105
2952
  "data-result-idx": i,
2106
- onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2107
- onMouseLeave: function (e) { e.currentTarget.style.background = isActive ? "rgba(79,140,255,0.16)" : "transparent"; },
2108
2953
  title: r.path || r.title,
2109
2954
  style: {
2110
2955
  padding: "4px 6px",
@@ -2112,23 +2957,23 @@ window.__ModuleLoader__.load({
2112
2957
  borderRadius: 6,
2113
2958
  cursor: "pointer",
2114
2959
  minWidth: 0,
2115
- background: isActive ? "rgba(79,140,255,0.16)" : "transparent",
2960
+ background: isActive ? C.chip : "transparent",
2116
2961
  transition: "background 0.15s ease"
2117
2962
  },
2118
2963
  children: children
2119
2964
  }, "res-" + i);
2120
2965
  }
2121
2966
 
2122
- function renderResults(rows, q, C, onRow, activeRow) {
2967
+ function renderResults(rows, q, C, onRow, activeRow, extra) {
2123
2968
  if (!rows || rows.length === 0) {
2124
2969
  return react_jsx_runtime.jsx("div", {
2125
2970
  style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
2126
- children: "没有匹配"
2127
- });
2971
+ children: T("search.empty")
2972
+ }, "empty");
2128
2973
  }
2129
2974
  var out = [];
2130
2975
  for (var i = 0; i < rows.length; i++) {
2131
- out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
2976
+ out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow, extra));
2132
2977
  }
2133
2978
  return out;
2134
2979
  }
@@ -2139,9 +2984,8 @@ window.__ModuleLoader__.load({
2139
2984
  // Implemented as a function (not an inline closure in a loop) so each
2140
2985
  // header captures its own (g, gi) — the var-in-loop closure bug would
2141
2986
  // otherwise make every header jump to the last group.
2142
- function renderGroupHeader(g, gi, jump, C) {
2987
+ function renderGroupHeader(g, gi, jump, C, isActive, headingless) {
2143
2988
  var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
2144
- var targetKey = replyKey || g.userKey;
2145
2989
  var jumpToTurn = function (e) {
2146
2990
  e.stopPropagation();
2147
2991
  // prefer the model's reply; fall back to the user message, then to the
@@ -2152,30 +2996,52 @@ window.__ModuleLoader__.load({
2152
2996
  };
2153
2997
  return react_jsx_runtime.jsx("div", {
2154
2998
  style: {
2155
- padding: "1px 4px 2px",
2156
- height: 18,
2999
+ padding: headingless ? "3px 4px" : "1px 4px 2px",
3000
+ height: headingless ? undefined : 18,
2157
3001
  display: "flex",
2158
3002
  alignItems: "center",
2159
3003
  minWidth: 0,
2160
3004
  // sticky: while you scroll the outline, the header of the group you are
2161
- // inside stays pinned at the top (background must be opaque to cover
2162
- // 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).
2163
3009
  position: "sticky",
2164
3010
  top: 0,
2165
3011
  zIndex: 2,
2166
3012
  background: C.panelBg
2167
3013
  },
2168
- 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", {
2169
3029
  onClick: jumpToTurn,
2170
- title: replyKey ? "跳转到该回合的模型回答开头" : "跳转到该回合开头",
3030
+ title: replyKey ? T("turn.jumpReply") : T("turn.jumpTurn"),
2171
3031
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
2172
3032
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
2173
3033
  style: {
2174
- fontSize: 11,
2175
- 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,
2176
3041
  cursor: "pointer",
2177
- padding: "1px 5px",
3042
+ padding: headingless ? "2px 6px" : "1px 5px",
2178
3043
  borderRadius: 4,
3044
+ background: "transparent",
2179
3045
  transition: "background 0.15s ease",
2180
3046
  display: "inline-flex",
2181
3047
  alignItems: "center",
@@ -2187,18 +3053,81 @@ window.__ModuleLoader__.load({
2187
3053
  children: [
2188
3054
  react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
2189
3055
  g.userText ? react_jsx_runtime.jsx("span", {
2190
- 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
+ },
2191
3064
  children: g.userText
2192
3065
  }) : null
2193
3066
  ]
2194
3067
  })
3068
+ ]
2195
3069
  }, "g-h-" + gi);
2196
3070
  }
2197
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
+
2198
3126
  // render each conversation turn as its own block: solid divider + time header
2199
3127
  // offset = global group index of the first rendered group (stable React keys)
2200
3128
  // activeIdx = array of groups being read (full opacity); others are dimmed
2201
- function renderGroups(groups, trees, jump, C, offset, activeIdx) {
3129
+ function renderGroups(groups, trees, jump, C, offset, activeIdx, extra) {
3130
+ extra = extra || {};
2202
3131
  var base = offset || 0;
2203
3132
  var activeSet = {};
2204
3133
  if (Array.isArray(activeIdx)) {
@@ -2208,24 +3137,55 @@ window.__ModuleLoader__.load({
2208
3137
  for (var i = 0; i < groups.length; i++) {
2209
3138
  var g = groups[i];
2210
3139
  var gi = base + i;
2211
- var items = [];
2212
- 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", {
2213
3145
  style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
2214
3146
  }, "g-sep-" + gi));
2215
- items.push(renderGroupHeader(g, gi, jump, C));
2216
- items.push(renderNodes(trees[i], 0, jump, C, "g" + gi));
2217
- 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;
2218
3160
  out.push(react_jsx_runtime.jsx("div", {
2219
3161
  "data-group-idx": gi,
2220
- 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
+ },
2221
3178
  children: items
2222
3179
  }, "g-" + gi));
2223
3180
  }
2224
3181
  return out;
2225
3182
  }
2226
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.
2227
3187
  var zh = {
2228
- "panel.title": "对话大纲"
3188
+ "panel.title": DICTS.zh["panel.title"]
2229
3189
  };
2230
3190
  var en = {
2231
3191
  "panel.title": "Conversation Outline"
@@ -2237,6 +3197,29 @@ window.__ModuleLoader__.load({
2237
3197
  ctx.effect(function () {
2238
3198
  return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
2239
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
+
2240
3223
  // Recent DSH (0.1.5-rc.1): session-scoped hooks (useChat/useSession/sessionId) only arrive
2241
3224
  // inside a declared session slot. Register the panel into the session-scoped
2242
3225
  // conversation.input.overlay (list/additive) so it receives useChat; the panel
@@ -2248,7 +3231,10 @@ window.__ModuleLoader__.load({
2248
3231
  id: "quick-toc",
2249
3232
  order: 90,
2250
3233
  locale: "dsh-quick-toc"
2251
- }, 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
+ });
2252
3238
  });
2253
3239
  }
2254
3240
 
@@ -2267,6 +3253,15 @@ window.__ModuleLoader__.load({
2267
3253
  // the level picker's enter/exit animations live here as classes so React
2268
3254
  // re-renders (which rewrite inline styles) cannot restart or cancel them
2269
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}" +
2270
3265
  // exit: starts moving immediately (fast attack), so the dismiss feels snappy
2271
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) —