dsh-quick-toc 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +41 -0
- package/CHANGELOG.md +41 -0
- package/README.en.md +34 -9
- package/README.md +34 -9
- package/lib/client.js +1548 -277
- package/package.json +5 -3
package/lib/client.js
CHANGED
|
@@ -21,6 +21,62 @@ 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
|
+
"outline.bottom": "回到底部",
|
|
56
|
+
"turn.jumpReply": "跳转到该回合的模型回答开头",
|
|
57
|
+
"turn.jumpTurn": "跳转到该回合开头",
|
|
58
|
+
"turn.loading": "正在加载这个回合…",
|
|
59
|
+
"turn.unloaded": "未加载",
|
|
60
|
+
"turn.loadTip": "点击加载这个回合并跳转过去",
|
|
61
|
+
"turn.failed": "请求失败",
|
|
62
|
+
"turn.jumpFailed": "跳转到该回合的报错位置",
|
|
63
|
+
"time.yesterday": "昨天",
|
|
64
|
+
"time.beforeYesterday": "前天",
|
|
65
|
+
"preview.truncated": "预览由宿主提供,可能被截断",
|
|
66
|
+
"resize.w": "拖拽调整宽度",
|
|
67
|
+
"resize.h": "拖拽调整高度",
|
|
68
|
+
"resize.wh": "拖拽同时调整宽高"
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
// The dictionary the current render reads. Kept as a module-level binding so
|
|
72
|
+
// the module-scope render helpers (renderItem/renderResultRow/...) can localise
|
|
73
|
+
// without threading a `t` argument through every call.
|
|
74
|
+
var activeDict = DICTS.zh;
|
|
75
|
+
function T(key) {
|
|
76
|
+
var s = activeDict[key];
|
|
77
|
+
return s === undefined ? key : s;
|
|
78
|
+
}
|
|
79
|
+
|
|
24
80
|
// ---------- markdown helpers ----------
|
|
25
81
|
function extractReplyText(node) {
|
|
26
82
|
var blocks = node && node.data && node.data.blocks;
|
|
@@ -78,6 +134,153 @@ window.__ModuleLoader__.load({
|
|
|
78
134
|
return first;
|
|
79
135
|
}
|
|
80
136
|
|
|
137
|
+
// ---------- normalized matching (fold, plus an optional fuzzy pass) ----------
|
|
138
|
+
// Folding ignores differences that never change meaning: letter case,
|
|
139
|
+
// full-width vs half-width forms (AB12,), and runs of whitespace. `map`
|
|
140
|
+
// keeps every folded character's ORIGINAL index, so a match found in folded
|
|
141
|
+
// space still highlights the untouched DOM text. Folding is ALWAYS on — it is
|
|
142
|
+
// correctness, not fuzziness: pasting "全角" text must find "全角" content.
|
|
143
|
+
function foldWithMap(text) {
|
|
144
|
+
var src = String(text === undefined || text === null ? "" : text);
|
|
145
|
+
var out = "";
|
|
146
|
+
var map = [];
|
|
147
|
+
var prevSpace = false;
|
|
148
|
+
for (var i = 0; i < src.length; i++) {
|
|
149
|
+
var ch = src.charAt(i);
|
|
150
|
+
var code = src.charCodeAt(i);
|
|
151
|
+
if (code >= 0xFF01 && code <= 0xFF5E) ch = String.fromCharCode(code - 0xFEE0); // full-width ASCII
|
|
152
|
+
else if (code === 0x3000) ch = " "; // ideographic space
|
|
153
|
+
var lower = ch.toLowerCase();
|
|
154
|
+
if (/\s/.test(lower)) {
|
|
155
|
+
if (prevSpace) continue; // collapse a whitespace run to one space
|
|
156
|
+
prevSpace = true;
|
|
157
|
+
out += " ";
|
|
158
|
+
map.push(i);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
prevSpace = false;
|
|
162
|
+
out += lower;
|
|
163
|
+
map.push(i);
|
|
164
|
+
}
|
|
165
|
+
map.push(src.length); // end sentinel: source index just past the last character
|
|
166
|
+
return { text: out, map: map };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function foldQuery(q) {
|
|
170
|
+
return foldWithMap(q).text.trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Contiguous occurrences of the folded query — the panel's original
|
|
174
|
+
// behaviour (case-insensitive, non-overlapping), now fold-aware.
|
|
175
|
+
function exactMatches(folded, fq) {
|
|
176
|
+
var hits = [];
|
|
177
|
+
if (!fq) return hits;
|
|
178
|
+
var at = 0;
|
|
179
|
+
while ((at = folded.text.indexOf(fq, at)) !== -1) {
|
|
180
|
+
hits.push({ ranges: [[folded.map[at], folded.map[at + fq.length]]] });
|
|
181
|
+
at += fq.length;
|
|
182
|
+
}
|
|
183
|
+
return hits;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// How far apart the matched characters of one fuzzy hit may sit. Without a
|
|
187
|
+
// bound, "提交" would match any text that happens to contain 提…交 somewhere,
|
|
188
|
+
// which buries the real hits; the span keeps fuzzy useful for "关键字中间夹
|
|
189
|
+
// 了别的字" without turning the list into noise.
|
|
190
|
+
function fuzzySpan(fq) {
|
|
191
|
+
return Math.max(12, fq.length * 2 + 8);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Subsequence occurrences (fuzzy mode): the query's characters must appear in
|
|
195
|
+
// order, each hit is the shortest window found from the current position, and
|
|
196
|
+
// hits never overlap. Adjacent matched characters are merged into one range so
|
|
197
|
+
// a contiguous hit still highlights as a whole word.
|
|
198
|
+
function fuzzyMatches(folded, fq) {
|
|
199
|
+
var hits = [];
|
|
200
|
+
if (!fq) return hits;
|
|
201
|
+
var span = fuzzySpan(fq);
|
|
202
|
+
var from = 0;
|
|
203
|
+
for (;;) {
|
|
204
|
+
var start = -1;
|
|
205
|
+
var at = from;
|
|
206
|
+
var ranges = [];
|
|
207
|
+
var ok = true;
|
|
208
|
+
for (var i = 0; i < fq.length; i++) {
|
|
209
|
+
var hit = folded.text.indexOf(fq.charAt(i), at);
|
|
210
|
+
if (hit === -1 || (start !== -1 && hit - start > span)) { ok = false; break; }
|
|
211
|
+
if (start === -1) start = hit;
|
|
212
|
+
var raw = [folded.map[hit], folded.map[hit + 1]];
|
|
213
|
+
var last = ranges.length > 0 ? ranges[ranges.length - 1] : null;
|
|
214
|
+
if (last !== null && last[1] === raw[0] && folded.text.charAt(hit - 1) !== " ") last[1] = raw[1];
|
|
215
|
+
else ranges.push(raw);
|
|
216
|
+
at = hit + 1;
|
|
217
|
+
}
|
|
218
|
+
if (!ok) break;
|
|
219
|
+
hits.push({ ranges: ranges });
|
|
220
|
+
from = at;
|
|
221
|
+
if (from >= folded.text.length) break;
|
|
222
|
+
}
|
|
223
|
+
return hits;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function findMatches(folded, fq, fuzzy) {
|
|
227
|
+
if (!fq) return [];
|
|
228
|
+
return fuzzy ? fuzzyMatches(folded, fq) : exactMatches(folded, fq);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Number of hits of the folded query in `text` (used for ×N and n/N counts).
|
|
232
|
+
function countOccurrences(text, q, fuzzy) {
|
|
233
|
+
var fq = q && q.folded !== undefined ? q.folded : foldQuery(q);
|
|
234
|
+
if (!text || !fq) return 0;
|
|
235
|
+
return findMatches(foldWithMap(text), fq, !!fuzzy).length;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- section bodies: subtitle + hover preview ----------
|
|
239
|
+
// Body lines of one heading: everything after it up to the next heading in
|
|
240
|
+
// the same message.
|
|
241
|
+
function sectionPreview(lines, from, to, max) {
|
|
242
|
+
if (!lines || from >= to) return "";
|
|
243
|
+
var out = "";
|
|
244
|
+
var fence = null;
|
|
245
|
+
for (var i = from; i < to; i++) {
|
|
246
|
+
var line = lines[i];
|
|
247
|
+
var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
248
|
+
if (fence !== null) {
|
|
249
|
+
if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
|
|
253
|
+
var t = line.trim();
|
|
254
|
+
if (t === "") continue;
|
|
255
|
+
out += (out === "" ? "" : " ") + t;
|
|
256
|
+
if (out.length >= max) return out.slice(0, max).trim() + "…";
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// First readable sentence of a section — the outline row's subtitle. Skips
|
|
262
|
+
// blank lines, code fences, table rules and bare bullet/emphasis markers so a
|
|
263
|
+
// "###" block that opens with a table still gets a meaningful line.
|
|
264
|
+
function firstSentence(lines, from, to, max) {
|
|
265
|
+
if (!lines || from >= to) return "";
|
|
266
|
+
var fence = null;
|
|
267
|
+
for (var i = from; i < to; i++) {
|
|
268
|
+
var line = lines[i];
|
|
269
|
+
var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
270
|
+
if (fence !== null) {
|
|
271
|
+
if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
|
|
275
|
+
var t = line.trim().replace(/^[>+\-*]\s+/, "").replace(/^\d+[.)]\s+/, "");
|
|
276
|
+
if (t === "" || /^[|\-=*_\s]+$/.test(t)) continue;
|
|
277
|
+
t = cleanTitle(t);
|
|
278
|
+
if (t === "") continue;
|
|
279
|
+
return t.length > max ? t.slice(0, max).trim() + "…" : t;
|
|
280
|
+
}
|
|
281
|
+
return "";
|
|
282
|
+
}
|
|
283
|
+
|
|
81
284
|
// Headings are collected line by line so FENCED CODE BLOCKS can be skipped:
|
|
82
285
|
// a "```" block that documents markdown (or shows a shell comment like
|
|
83
286
|
// "# install") contains lines that look like headings but are code — they
|
|
@@ -99,18 +302,18 @@ window.__ModuleLoader__.load({
|
|
|
99
302
|
}
|
|
100
303
|
if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
|
|
101
304
|
var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
102
|
-
if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
|
|
305
|
+
if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()), line: i });
|
|
103
306
|
}
|
|
104
307
|
return items;
|
|
105
308
|
}
|
|
106
309
|
|
|
107
|
-
function buildTree(headings) {
|
|
310
|
+
function buildTree(headings, time) {
|
|
108
311
|
var root = { level: 0, children: [] };
|
|
109
312
|
var stack = [root];
|
|
110
313
|
for (var i = 0; i < headings.length; i++) {
|
|
111
314
|
var h = headings[i];
|
|
112
315
|
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: [] };
|
|
316
|
+
var node = { level: h.level, title: h.title, key: h.key, idx: h.idx, sub: h.sub, preview: h.preview, time: time, children: [] };
|
|
114
317
|
stack[stack.length - 1].children.push(node);
|
|
115
318
|
stack.push(node);
|
|
116
319
|
}
|
|
@@ -122,6 +325,7 @@ window.__ModuleLoader__.load({
|
|
|
122
325
|
// so a streaming update only re-joins/re-parses the node that actually
|
|
123
326
|
// changed instead of every message in the history.
|
|
124
327
|
var EMPTY_HEADINGS = [];
|
|
328
|
+
var EMPTY_LINES = [];
|
|
125
329
|
var nodeInfoCache = new Map();
|
|
126
330
|
function nodeInfo(key, node) {
|
|
127
331
|
var isUser = node.kind === "user";
|
|
@@ -132,12 +336,24 @@ window.__ModuleLoader__.load({
|
|
|
132
336
|
return cached;
|
|
133
337
|
}
|
|
134
338
|
var text = isUser ? extractUserText(node) : extractReplyText(node);
|
|
339
|
+
var lines = text ? text.split("\n") : EMPTY_LINES;
|
|
340
|
+
var headings = node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS;
|
|
341
|
+
// per-heading subtitle (first sentence) + hover preview (section opening) —
|
|
342
|
+
// computed here, with the parse, so streaming updates only pay for the node
|
|
343
|
+
// that actually changed. `line` marks where the heading sits in `lines`, and
|
|
344
|
+
// its section runs to the next heading of ANY level (or the message end).
|
|
345
|
+
for (var hi = 0; hi < headings.length; hi++) {
|
|
346
|
+
var toLine = hi + 1 < headings.length ? headings[hi + 1].line : lines.length;
|
|
347
|
+
headings[hi].sub = firstSentence(lines, headings[hi].line + 1, toLine, 44);
|
|
348
|
+
headings[hi].preview = sectionPreview(lines, headings[hi].line + 1, toLine, 260);
|
|
349
|
+
}
|
|
135
350
|
var info = {
|
|
136
351
|
node: node,
|
|
137
352
|
blocks: blocks,
|
|
138
353
|
user: isUser,
|
|
139
354
|
text: text,
|
|
140
|
-
|
|
355
|
+
lines: lines,
|
|
356
|
+
headings: headings
|
|
141
357
|
};
|
|
142
358
|
nodeInfoCache.set(key, info);
|
|
143
359
|
return info;
|
|
@@ -155,33 +371,44 @@ window.__ModuleLoader__.load({
|
|
|
155
371
|
return h.key + "#" + h.idx;
|
|
156
372
|
}
|
|
157
373
|
|
|
158
|
-
// Split `text` into runs around
|
|
159
|
-
//
|
|
160
|
-
|
|
374
|
+
// Split `text` into runs around the hits of `q`, so the caller can render the
|
|
375
|
+
// hits distinctly. Returns [{ text, hit }, ...]. Runs on folded text, so a
|
|
376
|
+
// full-width or differently-cased match is highlighted where it really sits,
|
|
377
|
+
// and a fuzzy hit paints each matched character run.
|
|
378
|
+
function highlightParts(text, q, fuzzy) {
|
|
161
379
|
var parts = [];
|
|
162
380
|
if (!text) return parts;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
var
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
381
|
+
var fq = foldQuery(q);
|
|
382
|
+
if (!fq) return [{ text: text, hit: false }];
|
|
383
|
+
var hits = findMatches(foldWithMap(text), fq, !!fuzzy);
|
|
384
|
+
if (hits.length === 0) return [{ text: text, hit: false }];
|
|
385
|
+
var cursor = 0;
|
|
386
|
+
for (var i = 0; i < hits.length; i++) {
|
|
387
|
+
var ranges = hits[i].ranges;
|
|
388
|
+
for (var r = 0; r < ranges.length; r++) {
|
|
389
|
+
var from = ranges[r][0];
|
|
390
|
+
var to = ranges[r][1];
|
|
391
|
+
if (from < cursor || to <= from) continue;
|
|
392
|
+
if (from > cursor) parts.push({ text: text.slice(cursor, from), hit: false });
|
|
393
|
+
parts.push({ text: text.slice(from, to), hit: true });
|
|
394
|
+
cursor = to;
|
|
395
|
+
}
|
|
172
396
|
}
|
|
173
|
-
if (
|
|
397
|
+
if (cursor < text.length) parts.push({ text: text.slice(cursor), hit: false });
|
|
174
398
|
return parts;
|
|
175
399
|
}
|
|
176
400
|
|
|
177
401
|
// A one-line window around the first hit of `q` in `text` (for search results).
|
|
178
|
-
function snippetAround(text, q, span) {
|
|
402
|
+
function snippetAround(text, q, span, fuzzy) {
|
|
179
403
|
if (!text) return "";
|
|
180
404
|
var flat = text.replace(/\s+/g, " ").trim();
|
|
181
|
-
var
|
|
182
|
-
|
|
405
|
+
var fq = foldQuery(q);
|
|
406
|
+
var folded = fq ? foldWithMap(flat) : null;
|
|
407
|
+
var hits = folded ? findMatches(folded, fq, !!fuzzy) : [];
|
|
408
|
+
if (hits.length === 0) return previewText(flat, span * 2);
|
|
409
|
+
var at = folded.map[hits[0].ranges[0][0]];
|
|
183
410
|
var start = Math.max(0, at - span);
|
|
184
|
-
var end = Math.min(flat.length, at +
|
|
411
|
+
var end = Math.min(flat.length, at + span * 2);
|
|
185
412
|
return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
|
|
186
413
|
}
|
|
187
414
|
|
|
@@ -228,21 +455,6 @@ window.__ModuleLoader__.load({
|
|
|
228
455
|
}
|
|
229
456
|
|
|
230
457
|
// 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
458
|
// find the conversation's "load older messages" button (scoped to the
|
|
247
459
|
// conversation scrollport so panel buttons are never matched)
|
|
248
460
|
function findLoadOlderButton() {
|
|
@@ -320,10 +532,17 @@ window.__ModuleLoader__.load({
|
|
|
320
532
|
highlightSpans = [];
|
|
321
533
|
}
|
|
322
534
|
|
|
323
|
-
// wrap every
|
|
324
|
-
//
|
|
325
|
-
|
|
535
|
+
// wrap every hit of q inside row's text nodes; the hit at `currentOcc` gets a
|
|
536
|
+
// distinct "current" highlight. Matching runs on FOLDED text (case/full-width/
|
|
537
|
+
// whitespace insensitive) and, in fuzzy mode, on subsequences — a hit is then a
|
|
538
|
+
// set of character ranges, so one fuzzy hit can paint several spans.
|
|
539
|
+
// NOTE: these fills sit ON TOP OF conversation text, so they stay deliberately
|
|
540
|
+
// stronger than the panel's UI tints (C.chip / C.groupTint); only the current
|
|
541
|
+
// hit's outline reuses the shared accent value.
|
|
542
|
+
function highlightRow(row, q, currentOcc, fuzzy) {
|
|
326
543
|
if (!row || !q) return;
|
|
544
|
+
var fq = foldQuery(q);
|
|
545
|
+
if (!fq) return;
|
|
327
546
|
var walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null);
|
|
328
547
|
var textNodes = [];
|
|
329
548
|
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
|
@@ -332,38 +551,41 @@ window.__ModuleLoader__.load({
|
|
|
332
551
|
var node = textNodes[i];
|
|
333
552
|
var text = node.nodeValue;
|
|
334
553
|
if (!text) continue;
|
|
335
|
-
var
|
|
336
|
-
if (
|
|
554
|
+
var hits = findMatches(foldWithMap(text), fq, !!fuzzy);
|
|
555
|
+
if (hits.length === 0) continue;
|
|
337
556
|
var frag = document.createDocumentFragment();
|
|
338
|
-
// EVERY
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
557
|
+
// EVERY hit inside this text node must be wrapped, in order: the caller
|
|
558
|
+
// addresses hits by their global index (`occ`), which is counted over the
|
|
559
|
+
// message text. Wrapping only the first one per text node made a second
|
|
560
|
+
// hit on the same line un-markable, so stepping to it found no
|
|
342
561
|
// `.dqt-current` and fell back to a plain scroll with no highlight.
|
|
343
|
-
var
|
|
344
|
-
var
|
|
345
|
-
|
|
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);
|
|
562
|
+
var cursor = 0;
|
|
563
|
+
for (var h = 0; h < hits.length; h++) {
|
|
564
|
+
var isCurrent = occ === currentOcc;
|
|
361
565
|
occ++;
|
|
362
|
-
|
|
363
|
-
|
|
566
|
+
var ranges = hits[h].ranges;
|
|
567
|
+
for (var r = 0; r < ranges.length; r++) {
|
|
568
|
+
var from = ranges[r][0];
|
|
569
|
+
var to = ranges[r][1];
|
|
570
|
+
if (from < cursor || to <= from) continue;
|
|
571
|
+
if (from > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, from)));
|
|
572
|
+
var mark = document.createElement("span");
|
|
573
|
+
if (isCurrent) {
|
|
574
|
+
mark.className = "dqt-current";
|
|
575
|
+
mark.style.background = "rgba(79,140,255,0.55)";
|
|
576
|
+
mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
|
|
577
|
+
} else {
|
|
578
|
+
mark.style.background = "rgba(79,140,255,0.32)";
|
|
579
|
+
}
|
|
580
|
+
mark.style.borderRadius = "2px";
|
|
581
|
+
mark.style.color = "inherit";
|
|
582
|
+
mark.textContent = text.slice(from, to);
|
|
583
|
+
frag.appendChild(mark);
|
|
584
|
+
highlightSpans.push(mark);
|
|
585
|
+
cursor = to;
|
|
586
|
+
}
|
|
364
587
|
}
|
|
365
|
-
|
|
366
|
-
if (after) frag.appendChild(document.createTextNode(after));
|
|
588
|
+
if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor)));
|
|
367
589
|
node.parentNode.replaceChild(frag, node);
|
|
368
590
|
}
|
|
369
591
|
}
|
|
@@ -375,8 +597,18 @@ window.__ModuleLoader__.load({
|
|
|
375
597
|
text: "var(--dsw-alias-label-primary, #e8eaee)",
|
|
376
598
|
muted: "var(--dsw-alias-label-secondary, #9aa0ab)",
|
|
377
599
|
accent: "var(--dsw-alias-brand-primary, #4f8cff)",
|
|
600
|
+
// terminal turn failures (a reply that never arrived) get the host's own
|
|
601
|
+
// error colour, so the outline speaks the same language as the transcript
|
|
602
|
+
error: "var(--dsw-alias-state-error-primary, #e5534b)",
|
|
378
603
|
hover: "var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))",
|
|
379
|
-
|
|
604
|
+
// ONE source for every "this control is on / this button is open / this match
|
|
605
|
+
// is current" tint, so the panel cannot drift into five near-identical blues.
|
|
606
|
+
chip: "rgba(79, 140, 255, 0.18)",
|
|
607
|
+
// the followed group is a LARGE surface: same hue, lighter fill, with a
|
|
608
|
+
// closing outline + accent bar so the block reads as a finished shape.
|
|
609
|
+
groupTint: "rgba(79, 140, 255, 0.10)",
|
|
610
|
+
groupEdge: "rgba(79, 140, 255, 0.85)",
|
|
611
|
+
groupEdgeSoft: "rgba(79, 140, 255, 0.35)"
|
|
380
612
|
};
|
|
381
613
|
|
|
382
614
|
// ---- theme scheme detection (for the panel's 3D inner shadow) ----
|
|
@@ -444,7 +676,7 @@ window.__ModuleLoader__.load({
|
|
|
444
676
|
maxWidth: 340,
|
|
445
677
|
wordBreak: "break-all"
|
|
446
678
|
},
|
|
447
|
-
children: "
|
|
679
|
+
children: T("error.panel") + msg
|
|
448
680
|
});
|
|
449
681
|
}
|
|
450
682
|
return this.props.children;
|
|
@@ -466,6 +698,68 @@ window.__ModuleLoader__.load({
|
|
|
466
698
|
var order = useChat(function (s) { return s.order; });
|
|
467
699
|
var nodes = useChat(function (s) { return s.nodes; });
|
|
468
700
|
|
|
701
|
+
// Whole-log turn index: the host's `turnOutline` projection
|
|
702
|
+
// (@deepseek-ai/dsh-session-turn-outline) names EVERY turn of the session —
|
|
703
|
+
// including turns the paged event window has not loaded yet — with a bounded
|
|
704
|
+
// prompt/response preview and the `turn/start` seq that pages history to it.
|
|
705
|
+
// On a host without that projection the value is undefined and the panel
|
|
706
|
+
// behaves exactly like 0.4.1 (loaded turns only).
|
|
707
|
+
var useProjection = props.useProjection;
|
|
708
|
+
var rawOutline = useProjection ? useProjection("turnOutline") : null;
|
|
709
|
+
// { sessions() } bridge to the host session face, injected by apply().
|
|
710
|
+
// It is a LOOKUP (ctx.get), not a cached reference: the service may be
|
|
711
|
+
// registered after this package loads, and an unloaded provider must not
|
|
712
|
+
// leave a stale handle behind.
|
|
713
|
+
var tocHost = props.tocHost;
|
|
714
|
+
var sessionId = props.sessionId;
|
|
715
|
+
var hostSessions = function () {
|
|
716
|
+
if (!tocHost || typeof tocHost.sessions !== "function") return null;
|
|
717
|
+
try {
|
|
718
|
+
return tocHost.sessions();
|
|
719
|
+
} catch (e) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
// Which stage of the bridge is actually ready — reported once at mount so a
|
|
724
|
+
// missing jump loader can be diagnosed from the console instead of guessed.
|
|
725
|
+
var jumpLoaderState = function () {
|
|
726
|
+
var sessions = hostSessions();
|
|
727
|
+
if (!sessions) return "no-sessions-service";
|
|
728
|
+
if (typeof sessions.binding !== "function") return "no-binding-api";
|
|
729
|
+
var binding = null;
|
|
730
|
+
try {
|
|
731
|
+
binding = sessions.binding(sessionId);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
return "binding-threw";
|
|
734
|
+
}
|
|
735
|
+
var face = binding && binding.session;
|
|
736
|
+
if (!face) return "no-binding-for-session";
|
|
737
|
+
return typeof face.loadThrough === "function" ? "ready" : "no-loadThrough";
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// Structural narrowing of that projection (its value crosses the wire):
|
|
741
|
+
// `turn` and `seq` are load-bearing — an entry without them cannot be
|
|
742
|
+
// shown or jumped to — while the previews are decorative and degrade to "".
|
|
743
|
+
var outlineTurns = react.useMemo(function () {
|
|
744
|
+
var list = Array.isArray(rawOutline) ? rawOutline
|
|
745
|
+
: (rawOutline && Array.isArray(rawOutline.turns) ? rawOutline.turns : null);
|
|
746
|
+
if (!list) return null; // projection unavailable: loaded turns only
|
|
747
|
+
var out = [];
|
|
748
|
+
for (var i = 0; i < list.length; i++) {
|
|
749
|
+
var e = list[i];
|
|
750
|
+
if (!e || typeof e !== "object") continue;
|
|
751
|
+
if (typeof e.turn !== "number" || !isFinite(e.turn) || e.turn < 0) continue;
|
|
752
|
+
if (typeof e.seq !== "number" || !isFinite(e.seq) || e.seq < 0) continue;
|
|
753
|
+
out.push({
|
|
754
|
+
turn: e.turn,
|
|
755
|
+
seq: e.seq,
|
|
756
|
+
prompt: typeof e.prompt === "string" ? e.prompt : "",
|
|
757
|
+
response: typeof e.response === "string" ? e.response : ""
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}, [rawOutline]);
|
|
762
|
+
|
|
469
763
|
// ---- hooks (ALL before any conditional return) ----
|
|
470
764
|
// collapsed by default; user expands via the edge handle (default dock: left)
|
|
471
765
|
var _s1 = react.useState(false);
|
|
@@ -663,6 +957,114 @@ window.__ModuleLoader__.load({
|
|
|
663
957
|
var listRef = react.useRef(null);
|
|
664
958
|
var didInitScroll = react.useRef(false);
|
|
665
959
|
var outlineTouchRef = react.useRef(0); // last time the user touched the outline
|
|
960
|
+
var lastScrollTopRef = react.useRef(0);
|
|
961
|
+
var lastHostClickRef = react.useRef(0); // throttle for the "load earlier" click
|
|
962
|
+
|
|
963
|
+
// ---- "back to the newest row" button ------------------------------------
|
|
964
|
+
// The list can be scrolled far up into history (that is the whole point of
|
|
965
|
+
// paging), and the newest turns are then a long scroll away. The button
|
|
966
|
+
// appears whenever the list is NOT parked at its bottom and disappears once it
|
|
967
|
+
// is. `atBottomRef` mirrors the state so the scroll handler can tell "changed"
|
|
968
|
+
// from "same value" without depending on a stale render closure.
|
|
969
|
+
var _sAtBottom = react.useState(true);
|
|
970
|
+
var atBottom = _sAtBottom[0];
|
|
971
|
+
var setAtBottom = _sAtBottom[1];
|
|
972
|
+
var atBottomRef = react.useRef(true);
|
|
973
|
+
// a few px of slack: scrollTop is fractional and sub-pixel remainders are
|
|
974
|
+
// normal at the end of a smooth scroll
|
|
975
|
+
var syncAtBottom = function (el) {
|
|
976
|
+
if (!el) return;
|
|
977
|
+
var max = el.scrollHeight - el.clientHeight;
|
|
978
|
+
var next = max <= 4 || el.scrollTop >= max - 4;
|
|
979
|
+
if (next !== atBottomRef.current) {
|
|
980
|
+
atBottomRef.current = next;
|
|
981
|
+
setAtBottom(next);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
var scrollToBottom = function (e) {
|
|
985
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
986
|
+
outlineTouchRef.current = Date.now();
|
|
987
|
+
hoverEnd();
|
|
988
|
+
var el = listRef.current;
|
|
989
|
+
if (!el) return;
|
|
990
|
+
// optimistic: the list is on its way to the newest row, so the button starts
|
|
991
|
+
// fading at once; if the user interrupts the scroll the handler brings it back
|
|
992
|
+
atBottomRef.current = true;
|
|
993
|
+
setAtBottom(true);
|
|
994
|
+
if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
995
|
+
else el.scrollTop = el.scrollHeight;
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
// ---- "scroll up to load earlier messages" hint (search mode) -----------
|
|
999
|
+
// Armed by a search when older history is still reachable; dismissed the
|
|
1000
|
+
// moment the reader scrolls up (which is when the older page starts loading,
|
|
1001
|
+
// so the hint has done its job). "" | "more" | "oldest"
|
|
1002
|
+
var _sHint = react.useState("");
|
|
1003
|
+
var hint = _sHint[0];
|
|
1004
|
+
var setHint = _sHint[1];
|
|
1005
|
+
|
|
1006
|
+
// ---- transient edge banner (outline mode) ------------------------------
|
|
1007
|
+
// Reaching either end of the outline flashes a short bar over the bottom of
|
|
1008
|
+
// the list: fade in, hold a few seconds, fade out. Deliberately NOT the same
|
|
1009
|
+
// element as the search hint above (which stays until the reader acts).
|
|
1010
|
+
var _sToast = react.useState(null);
|
|
1011
|
+
var toast = _sToast[0];
|
|
1012
|
+
var setToast = _sToast[1];
|
|
1013
|
+
var toastTimersRef = react.useRef({ hold: null, out: null });
|
|
1014
|
+
var showBanner = function (text) {
|
|
1015
|
+
if (toastTimersRef.current.hold) clearTimeout(toastTimersRef.current.hold);
|
|
1016
|
+
if (toastTimersRef.current.out) clearTimeout(toastTimersRef.current.out);
|
|
1017
|
+
setToast({ text: text, closing: false });
|
|
1018
|
+
toastTimersRef.current.hold = setTimeout(function () {
|
|
1019
|
+
setToast(function (cur) { return cur === null ? null : { text: cur.text, closing: true }; });
|
|
1020
|
+
toastTimersRef.current.out = setTimeout(function () { setToast(null); }, 460);
|
|
1021
|
+
}, 2600);
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
// ---- hover preview card -------------------------------------------------
|
|
1025
|
+
// Hovering an outline/result row opens a card with the section's opening
|
|
1026
|
+
// lines; it lives in the body portal so the scrolling list cannot clip it.
|
|
1027
|
+
var _sHov = react.useState(null);
|
|
1028
|
+
var hoverCard = _sHov[0];
|
|
1029
|
+
var setHoverCard = _sHov[1];
|
|
1030
|
+
var _sHovOut = react.useState(false);
|
|
1031
|
+
var hoverClosing = _sHovOut[0];
|
|
1032
|
+
var setHoverClosing = _sHovOut[1];
|
|
1033
|
+
var hoverTimerRef = react.useRef(null);
|
|
1034
|
+
var hoverCloseTimerRef = react.useRef(null);
|
|
1035
|
+
var hoverStart = function (info, el) {
|
|
1036
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1037
|
+
hoverTimerRef.current = setTimeout(function () {
|
|
1038
|
+
var r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
1039
|
+
if (!r || r.width <= 0) return;
|
|
1040
|
+
if (hoverCloseTimerRef.current) {
|
|
1041
|
+
clearTimeout(hoverCloseTimerRef.current);
|
|
1042
|
+
hoverCloseTimerRef.current = null;
|
|
1043
|
+
}
|
|
1044
|
+
setHoverClosing(false);
|
|
1045
|
+
setHoverCard({
|
|
1046
|
+
title: info.title,
|
|
1047
|
+
sub: info.sub,
|
|
1048
|
+
preview: info.preview,
|
|
1049
|
+
meta: info.meta,
|
|
1050
|
+
ghost: info.ghost,
|
|
1051
|
+
top: r.top,
|
|
1052
|
+
left: r.left,
|
|
1053
|
+
right: r.right
|
|
1054
|
+
});
|
|
1055
|
+
}, 260);
|
|
1056
|
+
};
|
|
1057
|
+
// leaving fades the card out instead of dropping it on the next frame
|
|
1058
|
+
var hoverEnd = function () {
|
|
1059
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1060
|
+
if (hoverCloseTimerRef.current) clearTimeout(hoverCloseTimerRef.current);
|
|
1061
|
+
setHoverClosing(true);
|
|
1062
|
+
hoverCloseTimerRef.current = setTimeout(function () {
|
|
1063
|
+
hoverCloseTimerRef.current = null;
|
|
1064
|
+
setHoverCard(null);
|
|
1065
|
+
setHoverClosing(false);
|
|
1066
|
+
}, 240);
|
|
1067
|
+
};
|
|
666
1068
|
|
|
667
1069
|
// ---- the outline belongs to the CHAT view only: when the center column
|
|
668
1070
|
// switches to another view (trajectory / context / plugin views), fade the
|
|
@@ -734,6 +1136,21 @@ window.__ModuleLoader__.load({
|
|
|
734
1136
|
if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
|
|
735
1137
|
scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
|
|
736
1138
|
};
|
|
1139
|
+
// ---- fuzzy matching: an independent switch beside the scope pill --------
|
|
1140
|
+
// Off (default) = the folded CONTIGUOUS match. On = subsequence matching, so
|
|
1141
|
+
// keywords tolerate text wedged in between ("模糊匹配" also finds "模糊的匹配").
|
|
1142
|
+
var FUZZY_KEY = "dsh-quick-toc.fuzzy.v1";
|
|
1143
|
+
var _sFz = react.useState(function () {
|
|
1144
|
+
try { return localStorage.getItem(FUZZY_KEY) === "1"; } catch (e) { return false; }
|
|
1145
|
+
});
|
|
1146
|
+
var fuzzy = _sFz[0];
|
|
1147
|
+
var setFuzzy = _sFz[1];
|
|
1148
|
+
var toggleFuzzy = function () {
|
|
1149
|
+
var next = !fuzzy;
|
|
1150
|
+
setFuzzy(next);
|
|
1151
|
+
setMatchIdx(0);
|
|
1152
|
+
try { localStorage.setItem(FUZZY_KEY, next ? "1" : "0"); } catch (e) {}
|
|
1153
|
+
};
|
|
737
1154
|
var openSearch = function () {
|
|
738
1155
|
setSearchOpen(true);
|
|
739
1156
|
setSearchAnim("enter");
|
|
@@ -789,9 +1206,26 @@ window.__ModuleLoader__.load({
|
|
|
789
1206
|
}
|
|
790
1207
|
return false;
|
|
791
1208
|
};
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1209
|
+
var liftOffBottom = function () {
|
|
1210
|
+
// DSH re-pins the transcript to the newest message whenever it sees a
|
|
1211
|
+
// scroll it does not attribute to the reader while it still believes the
|
|
1212
|
+
// reader is at the bottom (`toBottom`). Paging history in from the OUTLINE
|
|
1213
|
+
// is exactly that case: the prepend compensation DSH applies is a
|
|
1214
|
+
// programmatic scroll, `atBottomRef` was never cleared (the reader never
|
|
1215
|
+
// touched the transcript), so the page-in is immediately undone and the
|
|
1216
|
+
// whole view — and with it the outline's follow — snaps back to the newest
|
|
1217
|
+
// turn. Nudging the transcript just past DSH's 25px stick zone first makes
|
|
1218
|
+
// that scroll read as a reader movement, so the page-in keeps its place.
|
|
1219
|
+
// (Same trick `glideTo` uses so a jump is not yanked back.)
|
|
1220
|
+
var sp = document.querySelector("[data-conversation-scroll]");
|
|
1221
|
+
if (!sp) return;
|
|
1222
|
+
var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
|
|
1223
|
+
if (floor - sp.scrollTop <= 25) sp.scrollTop = Math.max(0, floor - 26);
|
|
1224
|
+
};
|
|
1225
|
+
var viaHost = function () {
|
|
1226
|
+
var btn = findLoadOlderButton();
|
|
1227
|
+
if (!btn || btn.disabled) return false;
|
|
1228
|
+
liftOffBottom();
|
|
795
1229
|
btn.click();
|
|
796
1230
|
// once the conversation loads more, expand the outline window too
|
|
797
1231
|
setTimeout(function () {
|
|
@@ -804,7 +1238,34 @@ window.__ModuleLoader__.load({
|
|
|
804
1238
|
});
|
|
805
1239
|
}
|
|
806
1240
|
}, 1200);
|
|
807
|
-
|
|
1241
|
+
return true;
|
|
1242
|
+
};
|
|
1243
|
+
var viaHostThrottled = function () {
|
|
1244
|
+
var now = Date.now();
|
|
1245
|
+
if (now - lastHostClickRef.current < 900) return false;
|
|
1246
|
+
if (!findLoadOlderButton()) return false;
|
|
1247
|
+
lastHostClickRef.current = now;
|
|
1248
|
+
return viaHost();
|
|
1249
|
+
};
|
|
1250
|
+
// In SEARCH mode the list shows matches, not the outline: paging the
|
|
1251
|
+
// outline's own window would change nothing the reader can see, whereas
|
|
1252
|
+
// loading the conversation's older messages extends what the search can
|
|
1253
|
+
// cover at all (the whole-log index only carries bounded previews). So the
|
|
1254
|
+
// host's own "load earlier" comes first while a query is active.
|
|
1255
|
+
if (query.trim() !== "") return viaHost() || grow();
|
|
1256
|
+
// Outline mode keeps the ORIGINAL contract: scrolling the outline up also
|
|
1257
|
+
// pulls the conversation's older messages in (that is what makes the outline
|
|
1258
|
+
// a substitute for scrolling the transcript), not merely more index entries.
|
|
1259
|
+
// The host click is throttled so one continuous scroll cannot hammer the
|
|
1260
|
+
// pager; grow() still runs first so the index never lags behind the window.
|
|
1261
|
+
var grew = grow();
|
|
1262
|
+
return viaHostThrottled() || grew;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// older history still reachable? the outline can always page its own window
|
|
1266
|
+
// further, and beyond that the conversation's own "load older" button decides
|
|
1267
|
+
var canLoadOlder = function () {
|
|
1268
|
+
return visibleCount < groups.length || !!findLoadOlderButton();
|
|
808
1269
|
};
|
|
809
1270
|
|
|
810
1271
|
// wheel up (toward older) loads more when the list is at its top or has
|
|
@@ -812,17 +1273,53 @@ window.__ModuleLoader__.load({
|
|
|
812
1273
|
// always refreshes older turns, even without a visible scrollbar
|
|
813
1274
|
var onListWheel = function (e) {
|
|
814
1275
|
outlineTouchRef.current = Date.now();
|
|
815
|
-
if (e.deltaY >= 0) return;
|
|
816
1276
|
var el = listRef.current;
|
|
817
1277
|
if (!el) return;
|
|
818
|
-
|
|
1278
|
+
hoverEnd();
|
|
1279
|
+
if (e.deltaY >= 0) {
|
|
1280
|
+
// still scrolling down at the very end of the outline: say so. Outline
|
|
1281
|
+
// mode only — the search list has its own persistent hint line.
|
|
1282
|
+
var max = el.scrollHeight - el.clientHeight;
|
|
1283
|
+
if (max > 4 && el.scrollTop >= max - 4 && query.trim() === "") showBanner(T("hint.bottom"));
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (hint === "more") setHint(""); // scrolling up is what the hint asked for
|
|
1287
|
+
if (el.scrollTop <= 1) {
|
|
1288
|
+
var progressed = loadOlderOutline(el);
|
|
1289
|
+
if (!progressed) {
|
|
1290
|
+
if (query.trim() !== "") {
|
|
1291
|
+
if (hint !== "oldest") setHint("oldest");
|
|
1292
|
+
} else {
|
|
1293
|
+
showBanner(T("search.hintOldest"));
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
819
1297
|
};
|
|
820
1298
|
|
|
821
1299
|
// scroll to the top edge also loads older groups (keeps the visual position)
|
|
822
1300
|
var onListScroll = function (e) {
|
|
823
1301
|
outlineTouchRef.current = Date.now();
|
|
824
1302
|
var el = e.currentTarget;
|
|
825
|
-
|
|
1303
|
+
syncAtBottom(el);
|
|
1304
|
+
var upward = el.scrollTop < lastScrollTopRef.current - 2;
|
|
1305
|
+
if (upward) {
|
|
1306
|
+
if (hint === "more") setHint("");
|
|
1307
|
+
if (hoverCard) hoverEnd();
|
|
1308
|
+
}
|
|
1309
|
+
lastScrollTopRef.current = el.scrollTop;
|
|
1310
|
+
if (el.scrollTop <= 24) {
|
|
1311
|
+
// Reaching the top is the ONLY place the exhausted case can be told
|
|
1312
|
+
// apart from "keep scrolling": after a search the list is parked at the
|
|
1313
|
+
// bottom, so the first wheel-up merely scrolls and never gets here.
|
|
1314
|
+
var progressed = loadOlderOutline(el);
|
|
1315
|
+
if (!progressed && upward) {
|
|
1316
|
+
if (query.trim() !== "") {
|
|
1317
|
+
if (hint !== "oldest") setHint("oldest");
|
|
1318
|
+
} else {
|
|
1319
|
+
showBanner(T("search.hintOldest"));
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
826
1323
|
};
|
|
827
1324
|
|
|
828
1325
|
// ---- resize drags (right edge = width, bottom edge = height) ----
|
|
@@ -896,24 +1393,39 @@ window.__ModuleLoader__.load({
|
|
|
896
1393
|
window.addEventListener("pointerup", up);
|
|
897
1394
|
};
|
|
898
1395
|
|
|
899
|
-
// ---- node timestamp ->
|
|
900
|
-
var
|
|
901
|
-
if (t === undefined || t === null) return
|
|
1396
|
+
// ---- node timestamp -> Date (handles ms / s epochs / ISO strings) ----
|
|
1397
|
+
var toDate = function (t) {
|
|
1398
|
+
if (t === undefined || t === null) return null;
|
|
902
1399
|
if (typeof t === "string") {
|
|
903
1400
|
var d0 = new Date(t);
|
|
904
|
-
|
|
905
|
-
var hh0 = ("0" + d0.getHours()).slice(-2);
|
|
906
|
-
var mm0 = ("0" + d0.getMinutes()).slice(-2);
|
|
907
|
-
return hh0 + ":" + mm0;
|
|
1401
|
+
return isNaN(d0.getTime()) ? null : d0;
|
|
908
1402
|
}
|
|
909
1403
|
var n = Number(t);
|
|
910
|
-
if (!isFinite(n) || n <= 0) return
|
|
1404
|
+
if (!isFinite(n) || n <= 0) return null;
|
|
911
1405
|
if (n < 1e12) n = n * 1000; // epoch seconds -> ms
|
|
912
1406
|
var d = new Date(n);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1407
|
+
return isNaN(d.getTime()) ? null : d;
|
|
1408
|
+
};
|
|
1409
|
+
var pad2 = function (n) { return ("0" + n).slice(-2); };
|
|
1410
|
+
// ---- node timestamp -> HH:MM ----
|
|
1411
|
+
var fmtTime = function (t) {
|
|
1412
|
+
var d = toDate(t);
|
|
1413
|
+
return d ? pad2(d.getHours()) + ":" + pad2(d.getMinutes()) : "";
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// ---- turn stamp: the clock alone is ambiguous in a session that spans days,
|
|
1417
|
+
// so today stays "HH:MM", yesterday and the day before get a word, and anything
|
|
1418
|
+
// older gets its date as YY-MM-DD (weekday-free, sortable, short).
|
|
1419
|
+
var startOfDay = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
|
|
1420
|
+
var fmtStamp = function (t) {
|
|
1421
|
+
var d = toDate(t);
|
|
1422
|
+
if (!d) return "";
|
|
1423
|
+
var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
1424
|
+
var days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
|
|
1425
|
+
if (days <= 0) return clock; // today (a future skew also reads as today)
|
|
1426
|
+
if (days === 1) return T("time.yesterday") + " " + clock;
|
|
1427
|
+
if (days === 2) return T("time.beforeYesterday") + " " + clock;
|
|
1428
|
+
return pad2(d.getFullYear() % 100) + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock;
|
|
917
1429
|
};
|
|
918
1430
|
|
|
919
1431
|
// ---- best-effort node time across common field names ----
|
|
@@ -921,7 +1433,7 @@ window.__ModuleLoader__.load({
|
|
|
921
1433
|
var d = node && node.data;
|
|
922
1434
|
if (!d) return "";
|
|
923
1435
|
var t = d.time !== undefined ? d.time : (d.createdAt !== undefined ? d.createdAt : d.timestamp);
|
|
924
|
-
return
|
|
1436
|
+
return fmtStamp(t);
|
|
925
1437
|
};
|
|
926
1438
|
|
|
927
1439
|
// ---- group headings by conversation turn (node.location.turn) ----
|
|
@@ -934,12 +1446,18 @@ window.__ModuleLoader__.load({
|
|
|
934
1446
|
var turnUserFull = {};
|
|
935
1447
|
if (!order || !nodes) return result;
|
|
936
1448
|
var seen = {};
|
|
1449
|
+
// A turn's group is anchored by EVERY node that belongs to it — the user
|
|
1450
|
+
// message, its assistant steps, and the terminal `turn-error` the host
|
|
1451
|
+
// publishes when a request never produced a reply. Without the error node a
|
|
1452
|
+
// timed-out turn had no time and no headings, so it was dropped here and the
|
|
1453
|
+
// host outline re-listed it as "未加载" even though it was fully loaded.
|
|
1454
|
+
var anchorKind = function (kind) { return kind === "user" || kind === "assistant-step" || kind === "turn-error"; };
|
|
937
1455
|
// first pass: per-turn time — the LAST message of the turn wins (end time);
|
|
938
1456
|
// also remember each turn's user message key + first-line preview + full text
|
|
939
1457
|
for (var i = 0; i < order.length; i++) {
|
|
940
1458
|
var k0 = order[i];
|
|
941
1459
|
var n0 = nodes.get(k0);
|
|
942
|
-
if (!n0 || (n0.kind
|
|
1460
|
+
if (!n0 || !anchorKind(n0.kind)) continue;
|
|
943
1461
|
var l0 = n0.location;
|
|
944
1462
|
var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
|
|
945
1463
|
if (tid0 === null) continue;
|
|
@@ -953,11 +1471,12 @@ window.__ModuleLoader__.load({
|
|
|
953
1471
|
var t0 = getNodeTime(n0);
|
|
954
1472
|
if (t0) turnTimes[tid0] = t0;
|
|
955
1473
|
}
|
|
956
|
-
// second pass: group assistant headings + full reply texts by turn
|
|
1474
|
+
// second pass: group assistant headings + full reply texts by turn, and
|
|
1475
|
+
// record a turn's terminal failure so the outline can say WHY it is empty
|
|
957
1476
|
for (var j = 0; j < order.length; j++) {
|
|
958
1477
|
var key = order[j];
|
|
959
1478
|
var node = nodes.get(key);
|
|
960
|
-
if (!node || node.kind
|
|
1479
|
+
if (!node || !anchorKind(node.kind)) continue;
|
|
961
1480
|
var loc = node.location;
|
|
962
1481
|
var turnId = loc && (loc.kind === "turn" || loc.kind === "step") && loc.turn ? loc.turn.turn : null;
|
|
963
1482
|
var sameGroup = current !== null && current.turn === turnId;
|
|
@@ -968,23 +1487,79 @@ window.__ModuleLoader__.load({
|
|
|
968
1487
|
userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
|
|
969
1488
|
userText: turnId !== null ? (turnUserText[turnId] || "") : "",
|
|
970
1489
|
userFull: turnId !== null ? (turnUserFull[turnId] || "") : "",
|
|
1490
|
+
failure: null,
|
|
971
1491
|
msgs: [],
|
|
972
1492
|
headings: []
|
|
973
1493
|
};
|
|
974
1494
|
result.push(current);
|
|
975
1495
|
}
|
|
976
1496
|
seen[key] = true;
|
|
1497
|
+
if (node.kind === "turn-error") {
|
|
1498
|
+
// the host's terminal failure row: the turn ended without a reply, so
|
|
1499
|
+
// the outline shows the reason instead of pretending it is unloaded
|
|
1500
|
+
if (current.failure === null) {
|
|
1501
|
+
current.failure = {
|
|
1502
|
+
key: key,
|
|
1503
|
+
message: (node.data && node.data.message) || "",
|
|
1504
|
+
code: (node.data && node.data.code) || ""
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
if (node.kind !== "assistant-step") continue;
|
|
977
1510
|
var info = nodeInfo(key, node);
|
|
978
1511
|
current.msgs.push({ key: key, text: info.text });
|
|
979
1512
|
for (var k = 0; k < info.headings.length; k++) {
|
|
980
|
-
current.headings.push({
|
|
1513
|
+
current.headings.push({
|
|
1514
|
+
level: info.headings[k].level,
|
|
1515
|
+
title: info.headings[k].title,
|
|
1516
|
+
key: key,
|
|
1517
|
+
idx: k,
|
|
1518
|
+
sub: info.headings[k].sub,
|
|
1519
|
+
preview: info.headings[k].preview
|
|
1520
|
+
});
|
|
981
1521
|
}
|
|
982
1522
|
}
|
|
983
1523
|
pruneNodeInfo(seen);
|
|
984
|
-
// keep a turn when it has headings
|
|
985
|
-
// still get a standalone time entry
|
|
986
|
-
|
|
987
|
-
|
|
1524
|
+
// keep a turn when it has headings, a time, a loaded prompt or a failure —
|
|
1525
|
+
// turns without headings still get a standalone time entry (click to jump)
|
|
1526
|
+
var ready = result.filter(function (g) {
|
|
1527
|
+
return g.headings.length > 0 || g.time !== "" || g.userKey !== "" || g.failure !== null;
|
|
1528
|
+
});
|
|
1529
|
+
if (outlineTurns === null) return ready;
|
|
1530
|
+
// ---- merge the whole-log turn index ---------------------------------
|
|
1531
|
+
// Loaded groups keep their order (a group whose node carries no turn
|
|
1532
|
+
// location stays where it was found); every turn the paged window has not
|
|
1533
|
+
// loaded yet becomes an "unloaded" entry in the gap where it belongs, so
|
|
1534
|
+
// the outline covers the whole session and heading-less turns stay
|
|
1535
|
+
// reachable by number. Preview text comes from the projection and is
|
|
1536
|
+
// bounded by it (host-side 50/120 character budgets).
|
|
1537
|
+
var merged = [];
|
|
1538
|
+
var li = 0;
|
|
1539
|
+
for (var ei = 0; ei < outlineTurns.length; ei++) {
|
|
1540
|
+
var entry = outlineTurns[ei];
|
|
1541
|
+
while (li < ready.length && (ready[li].turn === null || ready[li].turn < entry.turn)) {
|
|
1542
|
+
merged.push(ready[li]);
|
|
1543
|
+
li++;
|
|
1544
|
+
}
|
|
1545
|
+
if (li < ready.length && ready[li].turn === entry.turn) continue; // emitted above
|
|
1546
|
+
merged.push({
|
|
1547
|
+
turn: entry.turn,
|
|
1548
|
+
seq: entry.seq,
|
|
1549
|
+
ghost: true,
|
|
1550
|
+
time: "",
|
|
1551
|
+
userKey: "",
|
|
1552
|
+
userText: entry.prompt,
|
|
1553
|
+
userFull: "",
|
|
1554
|
+
failure: null,
|
|
1555
|
+
msgs: [],
|
|
1556
|
+
headings: [],
|
|
1557
|
+
preview: entry.response
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
while (li < ready.length) { merged.push(ready[li]); li++; }
|
|
1561
|
+
return merged;
|
|
1562
|
+
}, [order, nodes, outlineTurns]);
|
|
988
1563
|
// first time content appears, scroll the list to the bottom (newest).
|
|
989
1564
|
// NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
|
|
990
1565
|
// during render, so reading `groups` before that `var` is assigned would
|
|
@@ -994,14 +1569,39 @@ window.__ModuleLoader__.load({
|
|
|
994
1569
|
didInitScroll.current = true;
|
|
995
1570
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
996
1571
|
}, [groups.length]);
|
|
1572
|
+
// The "back to the newest row" button follows the list's real geometry, and a
|
|
1573
|
+
// bottom can be reached WITHOUT a scroll event: older turns page in, a search
|
|
1574
|
+
// replaces the rows, the panel is resized, the panel is expanded/collapsed.
|
|
1575
|
+
// Also must stay below `groups` (deps arrays read the vars eagerly).
|
|
1576
|
+
react.useEffect(function () {
|
|
1577
|
+
syncAtBottom(listRef.current);
|
|
1578
|
+
}, [groups.length, query, visibleCount, panelW, panelH, open]);
|
|
1579
|
+
// Mount diagnostic: the host capabilities this panel actually reached
|
|
1580
|
+
// (whole-log turn index + jump loader). SILENT by default — a normal console
|
|
1581
|
+
// should stay clean — and switched on with
|
|
1582
|
+
// localStorage.setItem("dsh-quick-toc.debug", "1")
|
|
1583
|
+
// plus a reload. Kept because it is the fastest way to tell "the host has no
|
|
1584
|
+
// turnOutline projection" apart from "the jump-loader bridge is broken".
|
|
1585
|
+
var mountLogRef = react.useRef(false);
|
|
1586
|
+
react.useEffect(function () {
|
|
1587
|
+
if (mountLogRef.current) return;
|
|
1588
|
+
mountLogRef.current = true;
|
|
1589
|
+
var debug = false;
|
|
1590
|
+
try { debug = localStorage.getItem("dsh-quick-toc.debug") === "1"; } catch (e) { debug = false; }
|
|
1591
|
+
if (!debug) return;
|
|
1592
|
+
console.log("[dsh-quick-toc] panel mounted · turnOutline=" +
|
|
1593
|
+
(outlineTurns ? outlineTurns.length + " turns" : "unavailable") +
|
|
1594
|
+
" · jumpLoader=" + jumpLoaderState());
|
|
1595
|
+
}, [outlineTurns]);
|
|
1596
|
+
|
|
997
1597
|
var groupTrees = react.useMemo(function () {
|
|
998
1598
|
return groups.map(function (g) {
|
|
999
|
-
if (levels.length === 6) return buildTree(g.headings);
|
|
1599
|
+
if (levels.length === 6) return buildTree(g.headings, g.time);
|
|
1000
1600
|
var kept = [];
|
|
1001
1601
|
for (var i = 0; i < g.headings.length; i++) {
|
|
1002
1602
|
if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
|
|
1003
1603
|
}
|
|
1004
|
-
return buildTree(kept);
|
|
1604
|
+
return buildTree(kept, g.time);
|
|
1005
1605
|
});
|
|
1006
1606
|
}, [groups, levels, levelSet]);
|
|
1007
1607
|
// pagination slice: the latest `visibleCount` groups
|
|
@@ -1009,12 +1609,23 @@ window.__ModuleLoader__.load({
|
|
|
1009
1609
|
var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
|
|
1010
1610
|
|
|
1011
1611
|
// the group currently being read (the turn under the middle of the
|
|
1012
|
-
// CONVERSATION viewport) stays bright in the outline; others are dimmed
|
|
1612
|
+
// CONVERSATION viewport) stays bright in the outline; others are dimmed.
|
|
1613
|
+
// Every node key of every loaded group -> its group index: mapping ONLY the
|
|
1614
|
+
// heading-bearing messages meant a turn with no markdown headings had no
|
|
1615
|
+
// entry at all, so a jump that landed on it (the host's own turn rail, for
|
|
1616
|
+
// one) could never light up or follow in the outline.
|
|
1013
1617
|
var keyToGroup = react.useMemo(function () {
|
|
1014
1618
|
var m = {};
|
|
1015
1619
|
for (var i = 0; i < groups.length; i++) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1620
|
+
var g = groups[i];
|
|
1621
|
+
if (g.ghost) continue; // no loaded rows to match
|
|
1622
|
+
if (g.userKey) m[g.userKey] = i;
|
|
1623
|
+
if (g.failure) m[g.failure.key] = i;
|
|
1624
|
+
for (var j = 0; j < g.headings.length; j++) {
|
|
1625
|
+
m[g.headings[j].key] = i;
|
|
1626
|
+
}
|
|
1627
|
+
for (var k = 0; k < g.msgs.length; k++) {
|
|
1628
|
+
m[g.msgs[k].key] = i;
|
|
1018
1629
|
}
|
|
1019
1630
|
}
|
|
1020
1631
|
return m;
|
|
@@ -1045,8 +1656,9 @@ window.__ModuleLoader__.load({
|
|
|
1045
1656
|
// search result rows: one row per matched heading/message (with an
|
|
1046
1657
|
// occurrence count), carrying its heading path, turn time and a snippet.
|
|
1047
1658
|
var resultRows = react.useMemo(function () {
|
|
1048
|
-
var
|
|
1049
|
-
if (!
|
|
1659
|
+
var fq = foldQuery(query);
|
|
1660
|
+
if (!fq) return [];
|
|
1661
|
+
var needle = { folded: fq }; // one folded query, reused for every text
|
|
1050
1662
|
var rows = [];
|
|
1051
1663
|
var index = {};
|
|
1052
1664
|
var push = function (row, n) {
|
|
@@ -1059,43 +1671,95 @@ window.__ModuleLoader__.load({
|
|
|
1059
1671
|
};
|
|
1060
1672
|
for (var gi = 0; gi < groups.length; gi++) {
|
|
1061
1673
|
var g = groups[gi];
|
|
1674
|
+
// a turn the paged window has not loaded: searchable through the host
|
|
1675
|
+
// outline's bounded previews, and clicking it pages the turn in first
|
|
1676
|
+
if (g.ghost) {
|
|
1677
|
+
if (searchScope === "full") {
|
|
1678
|
+
var gtext = (g.userText || "") + "\n" + (g.preview || "");
|
|
1679
|
+
var ng = countOccurrences(gtext, needle, fuzzy);
|
|
1680
|
+
if (ng > 0) {
|
|
1681
|
+
push({
|
|
1682
|
+
gi: gi, key: "", idx: undefined,
|
|
1683
|
+
title: previewText(g.userText || g.preview || "", 40),
|
|
1684
|
+
level: 0, path: "", time: "",
|
|
1685
|
+
snippet: snippetAround(g.preview || g.userText || "", fq, 36, fuzzy),
|
|
1686
|
+
ghost: true, turn: g.turn, seq: g.seq
|
|
1687
|
+
}, ng);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1062
1692
|
for (var j = 0; j < g.headings.length; j++) {
|
|
1063
1693
|
var h = g.headings[j];
|
|
1064
|
-
var nh = countOccurrences(h.title,
|
|
1694
|
+
var nh = countOccurrences(h.title, needle, fuzzy);
|
|
1065
1695
|
if (nh > 0) {
|
|
1066
1696
|
var hp = headingPaths[headingId(h)];
|
|
1067
1697
|
push({
|
|
1068
1698
|
gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
|
|
1069
|
-
|
|
1699
|
+
// the subtitle identifies same-titled headings ("which one?")
|
|
1700
|
+
path: hp ? hp.path : "", time: g.time, snippet: h.sub || ""
|
|
1070
1701
|
}, nh);
|
|
1071
1702
|
}
|
|
1072
1703
|
}
|
|
1073
1704
|
if (searchScope === "full") {
|
|
1074
|
-
var nu = g.userKey && g.userFull ? countOccurrences(g.userFull,
|
|
1705
|
+
var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, needle, fuzzy) : 0;
|
|
1075
1706
|
if (nu > 0) {
|
|
1076
1707
|
push({
|
|
1077
1708
|
gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
|
|
1078
|
-
level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull,
|
|
1709
|
+
level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, fq, 36, fuzzy)
|
|
1079
1710
|
}, nu);
|
|
1080
1711
|
}
|
|
1081
1712
|
for (var m = 0; m < g.msgs.length; m++) {
|
|
1082
1713
|
var msg = g.msgs[m];
|
|
1083
1714
|
if (!msg.text) continue;
|
|
1084
|
-
var nm = countOccurrences(msg.text,
|
|
1715
|
+
var nm = countOccurrences(msg.text, needle, fuzzy);
|
|
1085
1716
|
if (nm === 0) continue;
|
|
1086
1717
|
push({
|
|
1087
1718
|
gi: gi, key: msg.key, idx: undefined, title: previewText(msg.text, 40),
|
|
1088
|
-
level: 0, path: "", time: g.time, snippet: snippetAround(msg.text,
|
|
1719
|
+
level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, fq, 36, fuzzy)
|
|
1089
1720
|
}, nm);
|
|
1090
1721
|
}
|
|
1722
|
+
// a failed turn's provider message is searchable too — "timed out" must
|
|
1723
|
+
// find the turns it killed
|
|
1724
|
+
if (g.failure && g.failure.message) {
|
|
1725
|
+
var nf = countOccurrences(g.failure.message, needle, fuzzy);
|
|
1726
|
+
if (nf > 0) {
|
|
1727
|
+
push({
|
|
1728
|
+
gi: gi, key: g.failure.key, idx: undefined, title: previewText(g.failure.message, 40),
|
|
1729
|
+
level: 0, path: "", time: g.time, failed: true,
|
|
1730
|
+
snippet: snippetAround(g.failure.message, fq, 36, fuzzy)
|
|
1731
|
+
}, nf);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1091
1734
|
}
|
|
1092
1735
|
}
|
|
1093
1736
|
return rows;
|
|
1094
|
-
}, [groups, query, searchScope, headingPaths]);
|
|
1737
|
+
}, [groups, query, searchScope, headingPaths, fuzzy]);
|
|
1738
|
+
// ---- auto-follow: which turn is the reader looking at -------------------
|
|
1739
|
+
// This used to hang off ONE scroll listener bound to the scrollport element
|
|
1740
|
+
// captured when the effect ran. DSH can replace that element (a jump that
|
|
1741
|
+
// repages the window remounts the list), and the listener then stays on the
|
|
1742
|
+
// detached node — the outline silently stops following. So it re-queries the
|
|
1743
|
+
// scrollport on every run, listens on the DOCUMENT in the capture phase
|
|
1744
|
+
// (scroll does not bubble; capture still sees every descendant), and keeps a
|
|
1745
|
+
// slow poll for programmatic anchor adjustments that emit no scroll event.
|
|
1746
|
+
//
|
|
1747
|
+
// The outline's own scroll is INSTANT and starts on the next frame: a following
|
|
1748
|
+
// rail that animates its way to the target reads as permanently one step behind.
|
|
1095
1749
|
react.useEffect(function () {
|
|
1096
|
-
var
|
|
1097
|
-
|
|
1098
|
-
var
|
|
1750
|
+
var followFrame = null;
|
|
1751
|
+
var followTimer = null;
|
|
1752
|
+
var lastRun = 0;
|
|
1753
|
+
var update = function (force) {
|
|
1754
|
+
// scroll frames are hot and a document-level capture listener sees every
|
|
1755
|
+
// scroll in the app; the rail only changes when a different turn crosses
|
|
1756
|
+
// the viewport, so cap the row measurement at ~16/s
|
|
1757
|
+
var now = Date.now();
|
|
1758
|
+
if (!force && now - lastRun < 60) return;
|
|
1759
|
+
lastRun = now;
|
|
1760
|
+
if (!chatViewRef.current) return;
|
|
1761
|
+
var sp = document.querySelector("[data-conversation-scroll]");
|
|
1762
|
+
if (!sp) return;
|
|
1099
1763
|
var lr = sp.getBoundingClientRect();
|
|
1100
1764
|
var vTop = lr.top;
|
|
1101
1765
|
var vBottom = lr.top + lr.height;
|
|
@@ -1111,37 +1775,43 @@ window.__ModuleLoader__.load({
|
|
|
1111
1775
|
}
|
|
1112
1776
|
}
|
|
1113
1777
|
var sig = actives.slice().sort().join(",");
|
|
1114
|
-
if (sig
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
}, 120);
|
|
1138
|
-
}
|
|
1778
|
+
if (sig === activeSigRef.current) return;
|
|
1779
|
+
activeSigRef.current = sig;
|
|
1780
|
+
setActiveGroup(actives);
|
|
1781
|
+
if (actives.length === 0) return;
|
|
1782
|
+
// pause right after the reader touched the outline themselves —
|
|
1783
|
+
// otherwise paging older turns gets yanked back to the newest group
|
|
1784
|
+
if (Date.now() - outlineTouchRef.current <= 1500) return;
|
|
1785
|
+
var gi0 = actives[0];
|
|
1786
|
+
setVisibleCount(function (prev) {
|
|
1787
|
+
var need = groups.length - gi0 + 3;
|
|
1788
|
+
return Math.max(prev, Math.min(groups.length, need));
|
|
1789
|
+
});
|
|
1790
|
+
if (followFrame) cancelAnimationFrame(followFrame);
|
|
1791
|
+
if (followTimer) clearTimeout(followTimer);
|
|
1792
|
+
var place = function () {
|
|
1793
|
+
var el = listRef.current;
|
|
1794
|
+
if (!el) return;
|
|
1795
|
+
var node = el.querySelector('[data-group-idx="' + gi0 + '"]');
|
|
1796
|
+
if (!node) return;
|
|
1797
|
+
var er = node.getBoundingClientRect();
|
|
1798
|
+
var lr2 = el.getBoundingClientRect();
|
|
1799
|
+
if (er.top < lr2.top - 2 || er.bottom > lr2.bottom + 2) {
|
|
1800
|
+
el.scrollTop = el.scrollTop + (er.top - lr2.top) - el.clientHeight / 2 + node.offsetHeight / 2;
|
|
1139
1801
|
}
|
|
1140
|
-
}
|
|
1802
|
+
};
|
|
1803
|
+
followFrame = requestAnimationFrame(function () { followFrame = requestAnimationFrame(place); });
|
|
1804
|
+
followTimer = setTimeout(place, 90); // the row may only exist after the expansion commits
|
|
1805
|
+
};
|
|
1806
|
+
update(true);
|
|
1807
|
+
document.addEventListener("scroll", update, { passive: true, capture: true });
|
|
1808
|
+
var poll = setInterval(function () { update(true); }, 250);
|
|
1809
|
+
return function () {
|
|
1810
|
+
document.removeEventListener("scroll", update, { capture: true });
|
|
1811
|
+
clearInterval(poll);
|
|
1812
|
+
if (followFrame) cancelAnimationFrame(followFrame);
|
|
1813
|
+
if (followTimer) clearTimeout(followTimer);
|
|
1141
1814
|
};
|
|
1142
|
-
update();
|
|
1143
|
-
sp.addEventListener("scroll", update, { passive: true });
|
|
1144
|
-
return function () { sp.removeEventListener("scroll", update); };
|
|
1145
1815
|
}, [keyToGroup]);
|
|
1146
1816
|
|
|
1147
1817
|
// ---- search matches ----
|
|
@@ -1150,21 +1820,32 @@ window.__ModuleLoader__.load({
|
|
|
1150
1820
|
// Every occurrence counts (multiple hits inside one message = multiple
|
|
1151
1821
|
// matches), so the n/N counter reflects the real total.
|
|
1152
1822
|
var matches = react.useMemo(function () {
|
|
1153
|
-
var
|
|
1154
|
-
if (!
|
|
1823
|
+
var fq = foldQuery(query);
|
|
1824
|
+
if (!fq) return [];
|
|
1825
|
+
var needle = { folded: fq };
|
|
1155
1826
|
var out = [];
|
|
1156
1827
|
for (var gi = 0; gi < groups.length; gi++) {
|
|
1157
1828
|
var g = groups[gi];
|
|
1829
|
+
if (g.ghost) {
|
|
1830
|
+
if (searchScope === "full") {
|
|
1831
|
+
var gtext = (g.userText || "") + "\n" + (g.preview || "");
|
|
1832
|
+
var ng = countOccurrences(gtext, needle, fuzzy);
|
|
1833
|
+
for (var cg = 0; cg < ng; cg++) {
|
|
1834
|
+
out.push({ gi: gi, title: g.userText || "", key: "", idx: undefined, ghost: true, turn: g.turn, seq: g.seq });
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1158
1839
|
for (var j = 0; j < g.headings.length; j++) {
|
|
1159
1840
|
var h = g.headings[j];
|
|
1160
|
-
var n = countOccurrences(h.title,
|
|
1841
|
+
var n = countOccurrences(h.title, needle, fuzzy);
|
|
1161
1842
|
for (var c = 0; c < n; c++) {
|
|
1162
1843
|
out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
|
|
1163
1844
|
}
|
|
1164
1845
|
}
|
|
1165
1846
|
if (searchScope === "full") {
|
|
1166
1847
|
if (g.userKey && g.userFull) {
|
|
1167
|
-
var nu = countOccurrences(g.userFull,
|
|
1848
|
+
var nu = countOccurrences(g.userFull, needle, fuzzy);
|
|
1168
1849
|
for (var cu = 0; cu < nu; cu++) {
|
|
1169
1850
|
out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
|
|
1170
1851
|
}
|
|
@@ -1172,7 +1853,7 @@ window.__ModuleLoader__.load({
|
|
|
1172
1853
|
for (var m = 0; m < g.msgs.length; m++) {
|
|
1173
1854
|
var msg = g.msgs[m];
|
|
1174
1855
|
if (!msg.text) continue;
|
|
1175
|
-
var nm = countOccurrences(msg.text,
|
|
1856
|
+
var nm = countOccurrences(msg.text, needle, fuzzy);
|
|
1176
1857
|
for (var cm = 0; cm < nm; cm++) {
|
|
1177
1858
|
out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
|
|
1178
1859
|
}
|
|
@@ -1180,7 +1861,7 @@ window.__ModuleLoader__.load({
|
|
|
1180
1861
|
}
|
|
1181
1862
|
}
|
|
1182
1863
|
return out;
|
|
1183
|
-
}, [groups, query, searchScope]);
|
|
1864
|
+
}, [groups, query, searchScope, fuzzy]);
|
|
1184
1865
|
|
|
1185
1866
|
// which result row holds the current (n/N) occurrence (needs `matches`)
|
|
1186
1867
|
var activeResultRow = -1;
|
|
@@ -1203,10 +1884,13 @@ window.__ModuleLoader__.load({
|
|
|
1203
1884
|
var q = query.trim();
|
|
1204
1885
|
if (searchPosRef.current === q) return;
|
|
1205
1886
|
searchPosRef.current = q;
|
|
1206
|
-
if (!q) return;
|
|
1887
|
+
if (!q) { setHint(""); return; }
|
|
1207
1888
|
var el = listRef.current;
|
|
1208
1889
|
if (el) el.scrollTop = el.scrollHeight;
|
|
1209
1890
|
if (matches.length > 0) setMatchIdx(matches.length - 1);
|
|
1891
|
+
// arm the "scroll up for older messages" hint: the result list only covers
|
|
1892
|
+
// what the window holds, and older turns are one upward scroll away
|
|
1893
|
+
setHint(canLoadOlder() ? "more" : "");
|
|
1210
1894
|
}, [query, matches.length]);
|
|
1211
1895
|
|
|
1212
1896
|
// keep the current row in view while stepping with Enter
|
|
@@ -1274,9 +1958,47 @@ window.__ModuleLoader__.load({
|
|
|
1274
1958
|
if (handleRef.current) handleRef.current.style.opacity = on ? "1" : "0.35";
|
|
1275
1959
|
};
|
|
1276
1960
|
|
|
1277
|
-
// ---- jump:
|
|
1278
|
-
//
|
|
1279
|
-
// the
|
|
1961
|
+
// ---- jump: glide to the exact heading element ---------------------------
|
|
1962
|
+
// A SMOOTH scroll is only trustworthy while the paged conversation keeps its
|
|
1963
|
+
// geometry. A LONG jump travels through the whole window, and the host mounts
|
|
1964
|
+
// and drops turns along the way, so the target's offset moves mid-flight and
|
|
1965
|
+
// the animation lands short ("it scrolled halfway and stopped"). Long jumps
|
|
1966
|
+
// therefore position INSTANTLY and then re-check for a short while (the same
|
|
1967
|
+
// "instant, never smooth" rule the outline's own follow uses), while nearby
|
|
1968
|
+
// headings keep the glide. When glued to the bottom, lift just past DSH's 25px
|
|
1969
|
+
// stick-to-bottom threshold first so the landing is not yanked back.
|
|
1970
|
+
var scrollTargetOf = function (el, sp) {
|
|
1971
|
+
var t = el.getBoundingClientRect().top - sp.getBoundingClientRect().top + sp.scrollTop - 20;
|
|
1972
|
+
var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
|
|
1973
|
+
return Math.max(0, Math.min(t, floor));
|
|
1974
|
+
};
|
|
1975
|
+
var glideTo = function (el) {
|
|
1976
|
+
var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
|
|
1977
|
+
if (!sp) {
|
|
1978
|
+
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
var t = scrollTargetOf(el, sp);
|
|
1982
|
+
var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
|
|
1983
|
+
if (floor - sp.scrollTop <= 25 && Math.abs(t - sp.scrollTop) > 60) {
|
|
1984
|
+
sp.scrollTop = Math.max(0, floor - 26);
|
|
1985
|
+
}
|
|
1986
|
+
if (Math.abs(t - sp.scrollTop) <= sp.clientHeight * 1.5) {
|
|
1987
|
+
sp.scrollTo({ top: t, behavior: "smooth" });
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
// long jump: land now, then keep the landing honest for ~0.6s while the host
|
|
1991
|
+
// finishes measuring the window it just re-paged
|
|
1992
|
+
sp.scrollTop = t;
|
|
1993
|
+
var ticks = 0;
|
|
1994
|
+
var settle = setInterval(function () {
|
|
1995
|
+
ticks++;
|
|
1996
|
+
var next = scrollTargetOf(el, sp);
|
|
1997
|
+
if (Math.abs(next - sp.scrollTop) > 2) sp.scrollTop = next;
|
|
1998
|
+
if (ticks >= 5) clearInterval(settle);
|
|
1999
|
+
}, 120);
|
|
2000
|
+
};
|
|
2001
|
+
|
|
1280
2002
|
var jump = function (key, idx) {
|
|
1281
2003
|
var row = findRow(key);
|
|
1282
2004
|
if (!row) return;
|
|
@@ -1285,17 +2007,94 @@ window.__ModuleLoader__.load({
|
|
|
1285
2007
|
var hs = row.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
1286
2008
|
if (hs.length > 0) el = hs[Math.min(idx, hs.length - 1)] || row;
|
|
1287
2009
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
2010
|
+
glideTo(el);
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
// ---- unloaded turns: page history to the turn, then land on it ----------
|
|
2014
|
+
// The host turn outline carries each turn's `turn/start` seq and the session
|
|
2015
|
+
// face's loadThrough(seq) is the documented jump loader ("page history
|
|
2016
|
+
// backwards until the window covers seq"). Once it resolves the turn's nodes
|
|
2017
|
+
// exist, so we poll briefly for its row — the native turn rail waits for the
|
|
2018
|
+
// same settle.
|
|
2019
|
+
var turnRow = function (turn) {
|
|
2020
|
+
try {
|
|
2021
|
+
return document.querySelector('[data-chat-turn="' + String(turn) + '"]');
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
return null;
|
|
1292
2024
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
2025
|
+
};
|
|
2026
|
+
|
|
2027
|
+
// Fallback locator for the poll below: once the paged window covers the
|
|
2028
|
+
// turn, OUR OWN rebuilt outline has a loaded group for it, which carries the
|
|
2029
|
+
// anchor keys of that turn's nodes. That path does not depend on the host
|
|
2030
|
+
// keeping a `data-chat-turn` attribute on the row.
|
|
2031
|
+
var groupsRef = react.useRef([]);
|
|
2032
|
+
react.useEffect(function () { groupsRef.current = groups; }, [groups]);
|
|
2033
|
+
var loadedGroupRow = function (turn) {
|
|
2034
|
+
var list = groupsRef.current || [];
|
|
2035
|
+
for (var i = 0; i < list.length; i++) {
|
|
2036
|
+
var g = list[i];
|
|
2037
|
+
if (g.ghost || g.turn !== turn) continue;
|
|
2038
|
+
var key = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : g.userKey;
|
|
2039
|
+
var row = key ? findRow(key) : null;
|
|
2040
|
+
if (row) return row;
|
|
2041
|
+
}
|
|
2042
|
+
return null;
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
var landOnTurn = function (turn) {
|
|
2046
|
+
var tries = 0;
|
|
2047
|
+
var step = function () {
|
|
2048
|
+
tries++;
|
|
2049
|
+
var row = turnRow(turn) || loadedGroupRow(turn);
|
|
2050
|
+
if (row) {
|
|
2051
|
+
glideTo(row);
|
|
2052
|
+
// a jump that started from a search hit keeps the hit highlighted
|
|
2053
|
+
if (query.trim()) {
|
|
2054
|
+
clearHighlights();
|
|
2055
|
+
highlightRow(row, query.trim(), 0, fuzzy);
|
|
2056
|
+
}
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
if (tries < 25) setTimeout(step, 80);
|
|
2060
|
+
};
|
|
2061
|
+
setTimeout(step, 40);
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// returns false when the host exposes no jump loader (older DSH builds)
|
|
2065
|
+
var openTurn = function (turn, seq) {
|
|
2066
|
+
var sessions = hostSessions();
|
|
2067
|
+
if (!sessions) {
|
|
2068
|
+
console.warn("[dsh-quick-toc] ctx.get(\"sessions\") returned nothing: this host has no session service, cannot open turn " + turn);
|
|
2069
|
+
return false;
|
|
1297
2070
|
}
|
|
1298
|
-
|
|
2071
|
+
var binding = typeof sessions.binding === "function" ? sessions.binding(sessionId) : null;
|
|
2072
|
+
var face = binding && binding.session;
|
|
2073
|
+
if (!face || typeof face.loadThrough !== "function") {
|
|
2074
|
+
console.warn("[dsh-quick-toc] session " + String(sessionId) + " exposes no loadThrough jump loader, cannot open turn " + turn);
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
setHoverCard(null);
|
|
2078
|
+
Promise.resolve(face.loadThrough(seq)).then(function () {
|
|
2079
|
+
landOnTurn(turn);
|
|
2080
|
+
}, function (err) {
|
|
2081
|
+
console.warn("[dsh-quick-toc] loadThrough failed:", err);
|
|
2082
|
+
});
|
|
2083
|
+
return true;
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
// reveal one group inside the outline list (shared by Enter-stepping and by
|
|
2087
|
+
// jumps into a turn that had to be loaded first)
|
|
2088
|
+
var revealGroupInOutline = function (gi) {
|
|
2089
|
+
setVisibleCount(function (prev) {
|
|
2090
|
+
return Math.max(prev, Math.min(groups.length, groups.length - gi));
|
|
2091
|
+
});
|
|
2092
|
+
setTimeout(function () {
|
|
2093
|
+
var el = listRef.current;
|
|
2094
|
+
if (!el) return;
|
|
2095
|
+
var node = el.querySelector('[data-group-idx="' + gi + '"]');
|
|
2096
|
+
if (node) node.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
2097
|
+
}, 150);
|
|
1299
2098
|
};
|
|
1300
2099
|
|
|
1301
2100
|
// jump to the n-th match, cycling; also reveal the group in the outline.
|
|
@@ -1306,14 +2105,21 @@ window.__ModuleLoader__.load({
|
|
|
1306
2105
|
var i = ((n % matches.length) + matches.length) % matches.length;
|
|
1307
2106
|
setMatchIdx(i);
|
|
1308
2107
|
var m = matches[i];
|
|
1309
|
-
var q = query.trim()
|
|
2108
|
+
var q = query.trim();
|
|
1310
2109
|
clearHighlights();
|
|
2110
|
+
if (m.ghost) {
|
|
2111
|
+
// the hit lives in a turn the paged window has not loaded: page it in
|
|
2112
|
+
// and land on it (landOnTurn re-applies the highlight there)
|
|
2113
|
+
openTurn(m.turn, m.seq);
|
|
2114
|
+
revealGroupInOutline(m.gi);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
1311
2117
|
var r = findRowStrict(m.key);
|
|
1312
2118
|
if (r && q) {
|
|
1313
2119
|
// occurrence index within the target message (consecutive in matches)
|
|
1314
2120
|
var occ = 0;
|
|
1315
2121
|
for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
|
|
1316
|
-
highlightRow(r, q, occ);
|
|
2122
|
+
highlightRow(r, q, occ, fuzzy);
|
|
1317
2123
|
var markEl = r.querySelector(".dqt-current");
|
|
1318
2124
|
var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
|
|
1319
2125
|
if (sp && markEl) {
|
|
@@ -1330,15 +2136,7 @@ window.__ModuleLoader__.load({
|
|
|
1330
2136
|
} else {
|
|
1331
2137
|
jump(m.key, m.idx);
|
|
1332
2138
|
}
|
|
1333
|
-
|
|
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);
|
|
2139
|
+
revealGroupInOutline(m.gi);
|
|
1342
2140
|
};
|
|
1343
2141
|
|
|
1344
2142
|
// clicking result row `i` = make its FIRST occurrence the current match, so
|
|
@@ -1395,6 +2193,47 @@ window.__ModuleLoader__.load({
|
|
|
1395
2193
|
};
|
|
1396
2194
|
|
|
1397
2195
|
// ---- panel ----
|
|
2196
|
+
// what the module-scope render helpers need from this render
|
|
2197
|
+
var renderExtra = {
|
|
2198
|
+
paths: headingPaths,
|
|
2199
|
+
fuzzy: fuzzy,
|
|
2200
|
+
onOpenTurn: openTurn,
|
|
2201
|
+
hoverStart: hoverStart,
|
|
2202
|
+
hoverEnd: hoverEnd
|
|
2203
|
+
};
|
|
2204
|
+
// Transient banner pinned to the bottom of the panel (the panel is
|
|
2205
|
+
// position:fixed, so this anchors to the list's lower edge without joining
|
|
2206
|
+
// the list's scroll flow). pointerEvents:none keeps it from eating clicks.
|
|
2207
|
+
var toastEl = toast ? react_jsx_runtime.jsx("div", {
|
|
2208
|
+
className: "dqt-toast" + (toast.closing ? " dqt-toast-closing" : ""),
|
|
2209
|
+
style: {
|
|
2210
|
+
position: "absolute",
|
|
2211
|
+
left: 8,
|
|
2212
|
+
right: 8,
|
|
2213
|
+
bottom: 8,
|
|
2214
|
+
display: "flex",
|
|
2215
|
+
justifyContent: "center",
|
|
2216
|
+
pointerEvents: "none",
|
|
2217
|
+
zIndex: 3
|
|
2218
|
+
},
|
|
2219
|
+
children: react_jsx_runtime.jsx("span", {
|
|
2220
|
+
style: {
|
|
2221
|
+
maxWidth: "100%",
|
|
2222
|
+
padding: "3px 10px",
|
|
2223
|
+
borderRadius: 999,
|
|
2224
|
+
cornerShape: "round",
|
|
2225
|
+
fontSize: 11,
|
|
2226
|
+
color: C.text,
|
|
2227
|
+
background: C.panelBg,
|
|
2228
|
+
border: "1px solid " + C.panelBorder,
|
|
2229
|
+
boxShadow: "0 2px 10px rgba(0,0,0,0.22)",
|
|
2230
|
+
whiteSpace: "nowrap",
|
|
2231
|
+
overflow: "hidden",
|
|
2232
|
+
textOverflow: "ellipsis"
|
|
2233
|
+
},
|
|
2234
|
+
children: toast.text
|
|
2235
|
+
})
|
|
2236
|
+
}) : null;
|
|
1398
2237
|
// panel: opening slides in with a slow fade; closing slides quickly to
|
|
1399
2238
|
// the dock edge, clipped by the sidebar line (looks covered, not
|
|
1400
2239
|
// dissolving) and only fades at the very end. No box-shadow: a shadow
|
|
@@ -1419,7 +2258,12 @@ window.__ModuleLoader__.load({
|
|
|
1419
2258
|
}
|
|
1420
2259
|
}
|
|
1421
2260
|
// faded out entirely while another center-column view is active
|
|
1422
|
-
|
|
2261
|
+
// Idle transparency used to be 0.45, which stacked with the outline's own
|
|
2262
|
+
// inactive-group dimming (0.6) into a wall of grey that left the panel barely
|
|
2263
|
+
// readable whenever the pointer was elsewhere. Readability now comes from
|
|
2264
|
+
// CONTRAST on the group being read (tinted row + accent bar, see
|
|
2265
|
+
// renderGroups) instead of from fading everything else away.
|
|
2266
|
+
var panelOpacity = open && chatViewActive ? (hovered ? 1 : 0.72) : 0;
|
|
1423
2267
|
|
|
1424
2268
|
var panelEl = react_jsx_runtime.jsx("div", {
|
|
1425
2269
|
style: {
|
|
@@ -1469,7 +2313,7 @@ window.__ModuleLoader__.load({
|
|
|
1469
2313
|
cursor: "grab",
|
|
1470
2314
|
userSelect: "none"
|
|
1471
2315
|
},
|
|
1472
|
-
title: "
|
|
2316
|
+
title: T("handle.dragY"),
|
|
1473
2317
|
onPointerDown: onHandleDown,
|
|
1474
2318
|
onMouseEnter: function () { handleBright(true); },
|
|
1475
2319
|
onMouseLeave: function () { handleBright(false); },
|
|
@@ -1499,34 +2343,20 @@ window.__ModuleLoader__.load({
|
|
|
1499
2343
|
gap: 6
|
|
1500
2344
|
},
|
|
1501
2345
|
children: [
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
title: "对话大纲",
|
|
1506
|
-
children: react_jsx_runtime.jsx("svg", {
|
|
1507
|
-
width: 18,
|
|
1508
|
-
height: 14,
|
|
1509
|
-
viewBox: "0 0 18 14",
|
|
1510
|
-
style: { display: "block" },
|
|
1511
|
-
children: [
|
|
1512
|
-
react_jsx_runtime.jsx("line", { x1: 0, y1: 2, x2: 18, y2: 2, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }),
|
|
1513
|
-
react_jsx_runtime.jsx("line", { x1: 0, y1: 7, x2: 12, y2: 7, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }),
|
|
1514
|
-
react_jsx_runtime.jsx("line", { x1: 0, y1: 12, x2: 6, y2: 12, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" })
|
|
1515
|
-
]
|
|
1516
|
-
})
|
|
1517
|
-
}),
|
|
2346
|
+
// LEFT pair: the heading-level filter and the dock toggle take the
|
|
2347
|
+
// left end — so the header reads two controls per side (search +
|
|
2348
|
+
// collapse sit at the other end).
|
|
1518
2349
|
react_jsx_runtime.jsx("div", {
|
|
1519
2350
|
style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
|
|
1520
2351
|
children: [
|
|
1521
|
-
// heading-level filter: a round icon button
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
// tint change; the resting look never shifts when open.
|
|
2352
|
+
// heading-level filter: a round icon button that pops down the
|
|
2353
|
+
// H1–H6 picker. Icon: three lines of decreasing width = outline
|
|
2354
|
+
// levels. Hover behaviour matches the magnifier: background +
|
|
2355
|
+
// icon tint change; the resting look never shifts when open.
|
|
1526
2356
|
react_jsx_runtime.jsx("button", {
|
|
1527
2357
|
className: "dqt-levels-btn",
|
|
1528
2358
|
onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
|
|
1529
|
-
title: "
|
|
2359
|
+
title: T("levels.tip"),
|
|
1530
2360
|
style: {
|
|
1531
2361
|
width: 24,
|
|
1532
2362
|
height: 24,
|
|
@@ -1539,8 +2369,10 @@ window.__ModuleLoader__.load({
|
|
|
1539
2369
|
display: "flex",
|
|
1540
2370
|
alignItems: "center",
|
|
1541
2371
|
justifyContent: "center",
|
|
1542
|
-
|
|
1543
|
-
|
|
2372
|
+
// open (or still animating shut) uses the SAME tint as every
|
|
2373
|
+
// other "on" control, so the header reads consistently
|
|
2374
|
+
background: (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
|
|
2375
|
+
color: (levelsOpen || levelsClosing) ? C.accent : C.muted,
|
|
1544
2376
|
transition: "background 0.15s ease, color 0.15s ease"
|
|
1545
2377
|
},
|
|
1546
2378
|
onMouseEnter: function (e) {
|
|
@@ -1548,8 +2380,8 @@ window.__ModuleLoader__.load({
|
|
|
1548
2380
|
e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
|
|
1549
2381
|
},
|
|
1550
2382
|
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;
|
|
2383
|
+
e.currentTarget.style.background = (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
|
|
2384
|
+
e.currentTarget.style.color = (levelsOpen || levelsClosing) ? C.accent : C.muted;
|
|
1553
2385
|
},
|
|
1554
2386
|
children: react_jsx_runtime.jsx("svg", {
|
|
1555
2387
|
width: 14,
|
|
@@ -1567,10 +2399,20 @@ window.__ModuleLoader__.load({
|
|
|
1567
2399
|
]
|
|
1568
2400
|
})
|
|
1569
2401
|
}),
|
|
2402
|
+
// dock toggle: the triangle tips toward the side it will move TO
|
|
2403
|
+
iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 })
|
|
2404
|
+
]
|
|
2405
|
+
}),
|
|
2406
|
+
// spacer: nothing here, it just pins the two pairs to the two ends
|
|
2407
|
+
react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto" } }),
|
|
2408
|
+
// RIGHT pair: search + collapse
|
|
2409
|
+
react_jsx_runtime.jsx("div", {
|
|
2410
|
+
style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
|
|
2411
|
+
children: [
|
|
1570
2412
|
// magnifier button (SVG, matches the other buttons' style)
|
|
1571
2413
|
react_jsx_runtime.jsx("button", {
|
|
1572
2414
|
onClick: function () { searchOpen ? closeSearch() : openSearch(); },
|
|
1573
|
-
title: "
|
|
2415
|
+
title: T("search.open"),
|
|
1574
2416
|
style: {
|
|
1575
2417
|
width: 24,
|
|
1576
2418
|
height: 24,
|
|
@@ -1579,9 +2421,9 @@ window.__ModuleLoader__.load({
|
|
|
1579
2421
|
justifyContent: "center",
|
|
1580
2422
|
borderRadius: "50%",
|
|
1581
2423
|
cornerShape: "round",
|
|
1582
|
-
background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
|
|
2424
|
+
background: searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
|
|
1583
2425
|
border: "none",
|
|
1584
|
-
color: C.muted,
|
|
2426
|
+
color: searchOpen ? C.accent : C.muted,
|
|
1585
2427
|
cursor: "pointer"
|
|
1586
2428
|
},
|
|
1587
2429
|
onMouseEnter: function (e) {
|
|
@@ -1589,8 +2431,8 @@ window.__ModuleLoader__.load({
|
|
|
1589
2431
|
e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
|
|
1590
2432
|
},
|
|
1591
2433
|
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;
|
|
2434
|
+
e.currentTarget.style.background = searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
|
|
2435
|
+
e.currentTarget.style.color = searchOpen ? C.accent : C.muted;
|
|
1594
2436
|
},
|
|
1595
2437
|
children: react_jsx_runtime.jsx("svg", {
|
|
1596
2438
|
width: 15,
|
|
@@ -1609,12 +2451,10 @@ window.__ModuleLoader__.load({
|
|
|
1609
2451
|
]
|
|
1610
2452
|
})
|
|
1611
2453
|
}),
|
|
1612
|
-
// 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 }),
|
|
1614
2454
|
// close: thick SVG cross, nudged slightly down
|
|
1615
2455
|
react_jsx_runtime.jsx("button", {
|
|
1616
2456
|
onClick: function () { setOpen(false); },
|
|
1617
|
-
title: "
|
|
2457
|
+
title: T("handle.collapse"),
|
|
1618
2458
|
style: {
|
|
1619
2459
|
width: 24,
|
|
1620
2460
|
height: 24,
|
|
@@ -1726,23 +2566,23 @@ window.__ModuleLoader__.load({
|
|
|
1726
2566
|
color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
|
|
1727
2567
|
},
|
|
1728
2568
|
children: [
|
|
1729
|
-
"
|
|
2569
|
+
T("search.word"),
|
|
1730
2570
|
react_jsx_runtime.jsx("span", {
|
|
1731
2571
|
style: { position: "relative", display: "inline-block" },
|
|
1732
2572
|
children: [
|
|
1733
2573
|
react_jsx_runtime.jsx("span", {
|
|
1734
2574
|
key: searchScope,
|
|
1735
2575
|
style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
|
|
1736
|
-
children: searchScope === "title" ? "
|
|
2576
|
+
children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
|
|
1737
2577
|
}),
|
|
1738
2578
|
(prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
|
|
1739
2579
|
key: "old-" + prevScope,
|
|
1740
2580
|
style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
|
|
1741
|
-
children: prevScope === "title" ? "
|
|
2581
|
+
children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
|
|
1742
2582
|
}) : null
|
|
1743
2583
|
]
|
|
1744
2584
|
}),
|
|
1745
|
-
"
|
|
2585
|
+
T("search.tail")
|
|
1746
2586
|
]
|
|
1747
2587
|
}) : null,
|
|
1748
2588
|
]
|
|
@@ -1771,7 +2611,7 @@ window.__ModuleLoader__.load({
|
|
|
1771
2611
|
// label text cross-fades old->new)
|
|
1772
2612
|
react_jsx_runtime.jsx("button", {
|
|
1773
2613
|
onClick: toggleScope,
|
|
1774
|
-
title: searchScope === "title" ? "
|
|
2614
|
+
title: searchScope === "title" ? T("search.scope.tipTitle") : T("search.scope.tipFull"),
|
|
1775
2615
|
style: {
|
|
1776
2616
|
flex: "none",
|
|
1777
2617
|
width: 34,
|
|
@@ -1783,7 +2623,7 @@ window.__ModuleLoader__.load({
|
|
|
1783
2623
|
cornerShape: "round",
|
|
1784
2624
|
fontSize: 11,
|
|
1785
2625
|
color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
|
|
1786
|
-
background: searchScope === "full" ?
|
|
2626
|
+
background: searchScope === "full" ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
|
|
1787
2627
|
border: "none",
|
|
1788
2628
|
cursor: "pointer",
|
|
1789
2629
|
whiteSpace: "nowrap",
|
|
@@ -1795,15 +2635,40 @@ window.__ModuleLoader__.load({
|
|
|
1795
2635
|
react_jsx_runtime.jsx("span", {
|
|
1796
2636
|
key: searchScope,
|
|
1797
2637
|
style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
|
|
1798
|
-
children: searchScope === "title" ? "
|
|
2638
|
+
children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
|
|
1799
2639
|
}),
|
|
1800
2640
|
(prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
|
|
1801
2641
|
key: "old-" + prevScope,
|
|
1802
2642
|
style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
|
|
1803
|
-
children: prevScope === "title" ? "
|
|
2643
|
+
children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
|
|
1804
2644
|
}) : null
|
|
1805
2645
|
]
|
|
1806
2646
|
})
|
|
2647
|
+
}),
|
|
2648
|
+
// fuzzy switch: an independent toggle beside the scope pill (its label
|
|
2649
|
+
// is the same either way, so only the colour carries the state)
|
|
2650
|
+
react_jsx_runtime.jsx("button", {
|
|
2651
|
+
onClick: toggleFuzzy,
|
|
2652
|
+
title: fuzzy ? T("search.fuzzy.tipOn") : T("search.fuzzy.tipOff"),
|
|
2653
|
+
"data-fuzzy": fuzzy ? "on" : "off",
|
|
2654
|
+
style: {
|
|
2655
|
+
flex: "none",
|
|
2656
|
+
width: 34,
|
|
2657
|
+
height: 34,
|
|
2658
|
+
display: "flex",
|
|
2659
|
+
alignItems: "center",
|
|
2660
|
+
justifyContent: "center",
|
|
2661
|
+
borderRadius: "50%",
|
|
2662
|
+
cornerShape: "round",
|
|
2663
|
+
fontSize: 11,
|
|
2664
|
+
color: fuzzy ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
|
|
2665
|
+
background: fuzzy ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
|
|
2666
|
+
border: "none",
|
|
2667
|
+
cursor: "pointer",
|
|
2668
|
+
whiteSpace: "nowrap",
|
|
2669
|
+
transition: "background 0.25s ease, color 0.25s ease"
|
|
2670
|
+
},
|
|
2671
|
+
children: T("search.fuzzy")
|
|
1807
2672
|
})
|
|
1808
2673
|
]
|
|
1809
2674
|
})
|
|
@@ -1830,10 +2695,97 @@ window.__ModuleLoader__.load({
|
|
|
1830
2695
|
children: react_jsx_runtime.jsx("div", {
|
|
1831
2696
|
style: { direction: "ltr" },
|
|
1832
2697
|
children: query.trim()
|
|
1833
|
-
?
|
|
1834
|
-
|
|
2698
|
+
? [
|
|
2699
|
+
react_jsx_runtime.jsx("div", {
|
|
2700
|
+
key: "results",
|
|
2701
|
+
children: renderResults(resultRows, query.trim(), C, goToRow, activeResultRow, renderExtra)
|
|
2702
|
+
}),
|
|
2703
|
+
// older history is one upward scroll away — say so until the
|
|
2704
|
+
// reader actually scrolls up (then the hint has done its job)
|
|
2705
|
+
hint ? react_jsx_runtime.jsx("div", {
|
|
2706
|
+
key: "hint",
|
|
2707
|
+
style: {
|
|
2708
|
+
display: "flex",
|
|
2709
|
+
alignItems: "center",
|
|
2710
|
+
justifyContent: "center",
|
|
2711
|
+
padding: "10px 6px 4px",
|
|
2712
|
+
fontSize: 11,
|
|
2713
|
+
color: C.muted,
|
|
2714
|
+
opacity: 0.85,
|
|
2715
|
+
textAlign: "center"
|
|
2716
|
+
},
|
|
2717
|
+
children: hint === "oldest" ? T("search.hintOldest") : T("search.hintMore")
|
|
2718
|
+
}) : null
|
|
2719
|
+
]
|
|
2720
|
+
: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup, renderExtra)
|
|
2721
|
+
})
|
|
2722
|
+
}),
|
|
2723
|
+
// "back to the newest row": floats over the list's lower-right corner and
|
|
2724
|
+
// fades in the moment the newest turns fall below the fold. Kept MOUNTED
|
|
2725
|
+
// and toggled by opacity/pointer-events — an unmounted button has nothing
|
|
2726
|
+
// to animate, and (like the level picker) its animation must not depend on
|
|
2727
|
+
// inline keyframes that every re-render rewrites.
|
|
2728
|
+
react_jsx_runtime.jsx("button", {
|
|
2729
|
+
onClick: scrollToBottom,
|
|
2730
|
+
className: "dqt-bottom-btn",
|
|
2731
|
+
"data-at-bottom": atBottom ? "on" : "off",
|
|
2732
|
+
title: T("outline.bottom"),
|
|
2733
|
+
tabIndex: atBottom ? -1 : 0,
|
|
2734
|
+
"aria-hidden": atBottom ? "true" : undefined,
|
|
2735
|
+
onMouseEnter: function (e) {
|
|
2736
|
+
e.currentTarget.style.color = C.accent;
|
|
2737
|
+
e.currentTarget.style.borderColor = C.groupEdgeSoft;
|
|
2738
|
+
},
|
|
2739
|
+
onMouseLeave: function (e) {
|
|
2740
|
+
e.currentTarget.style.color = C.text;
|
|
2741
|
+
e.currentTarget.style.borderColor = C.panelBorder;
|
|
2742
|
+
},
|
|
2743
|
+
style: {
|
|
2744
|
+
position: "absolute",
|
|
2745
|
+
// clear of the resize handle in the very corner (16x16)
|
|
2746
|
+
right: 18,
|
|
2747
|
+
bottom: 12,
|
|
2748
|
+
width: 26,
|
|
2749
|
+
height: 26,
|
|
2750
|
+
display: "flex",
|
|
2751
|
+
alignItems: "center",
|
|
2752
|
+
justifyContent: "center",
|
|
2753
|
+
padding: 0,
|
|
2754
|
+
// OPAQUE surface + outline + lift, the same vocabulary as the hover card:
|
|
2755
|
+
// a translucent chip disappeared into the rows behind it (and the panel's
|
|
2756
|
+
// own idle transparency multiplied it away). The surface is the host's
|
|
2757
|
+
// RAISED menu colour, not the panel background — a fill identical to the
|
|
2758
|
+
// panel it sits on reads as "no fill" and the button looks see-through.
|
|
2759
|
+
background: "var(--dsw-specific-menu, rgba(44, 49, 60, 0.99))",
|
|
2760
|
+
border: "1px solid " + C.panelBorder,
|
|
2761
|
+
borderRadius: "50%",
|
|
2762
|
+
cornerShape: "round",
|
|
2763
|
+
color: C.text,
|
|
2764
|
+
cursor: "pointer",
|
|
2765
|
+
zIndex: 6,
|
|
2766
|
+
boxShadow: "0 3px 10px rgba(0, 0, 0, 0.30)",
|
|
2767
|
+
opacity: atBottom ? 0 : 1,
|
|
2768
|
+
transform: atBottom ? "translateY(6px)" : "translateY(0)",
|
|
2769
|
+
pointerEvents: atBottom ? "none" : "auto",
|
|
2770
|
+
transition: "opacity 0.22s ease, transform 0.22s ease, background 0.18s ease, color 0.18s ease, border-color 0.18s ease"
|
|
2771
|
+
},
|
|
2772
|
+
children: react_jsx_runtime.jsx("svg", {
|
|
2773
|
+
width: 14,
|
|
2774
|
+
height: 14,
|
|
2775
|
+
viewBox: "0 0 14 14",
|
|
2776
|
+
fill: "none",
|
|
2777
|
+
stroke: "currentColor",
|
|
2778
|
+
strokeWidth: 1.8,
|
|
2779
|
+
strokeLinecap: "round",
|
|
2780
|
+
strokeLinejoin: "round",
|
|
2781
|
+
style: { display: "block" },
|
|
2782
|
+
children: [
|
|
2783
|
+
react_jsx_runtime.jsx("polyline", { points: "3.5,4.5 7,8 10.5,4.5" }),
|
|
2784
|
+
react_jsx_runtime.jsx("line", { x1: 3.5, y1: 11, x2: 10.5, y2: 11 })
|
|
2785
|
+
]
|
|
1835
2786
|
})
|
|
1836
2787
|
}),
|
|
2788
|
+
toastEl,
|
|
1837
2789
|
// resize handles (right edge: width, bottom edge: height)
|
|
1838
2790
|
react_jsx_runtime.jsx("div", {
|
|
1839
2791
|
style: {
|
|
@@ -1847,7 +2799,7 @@ window.__ModuleLoader__.load({
|
|
|
1847
2799
|
zIndex: 2
|
|
1848
2800
|
},
|
|
1849
2801
|
onPointerDown: onResizeWDown,
|
|
1850
|
-
title: "
|
|
2802
|
+
title: T("resize.w")
|
|
1851
2803
|
}),
|
|
1852
2804
|
react_jsx_runtime.jsx("div", {
|
|
1853
2805
|
style: {
|
|
@@ -1861,7 +2813,7 @@ window.__ModuleLoader__.load({
|
|
|
1861
2813
|
zIndex: 2
|
|
1862
2814
|
},
|
|
1863
2815
|
onPointerDown: onResizeHDown,
|
|
1864
|
-
title: "
|
|
2816
|
+
title: T("resize.h")
|
|
1865
2817
|
}),
|
|
1866
2818
|
// corner handle: resize width AND height at once
|
|
1867
2819
|
react_jsx_runtime.jsx("div", {
|
|
@@ -1876,10 +2828,11 @@ window.__ModuleLoader__.load({
|
|
|
1876
2828
|
zIndex: 3
|
|
1877
2829
|
},
|
|
1878
2830
|
onPointerDown: onResizeCornerDown,
|
|
1879
|
-
title: "
|
|
2831
|
+
title: T("resize.wh")
|
|
1880
2832
|
}),
|
|
1881
|
-
// heading-level picker: pops down from the round header button
|
|
1882
|
-
//
|
|
2833
|
+
// heading-level picker: pops down from the round header button and its
|
|
2834
|
+
// LEFT edge lines up with that button (the button now sits at the left end
|
|
2835
|
+
// of the header, so 10px is exactly the header's own left padding).
|
|
1883
2836
|
// Open/close animate from the button's center: the animation lives in
|
|
1884
2837
|
// the injected stylesheet (classes, not inline animation) so React
|
|
1885
2838
|
// re-renders never restart or cancel it — an inline `animation` set on
|
|
@@ -1889,7 +2842,7 @@ window.__ModuleLoader__.load({
|
|
|
1889
2842
|
style: {
|
|
1890
2843
|
position: "absolute",
|
|
1891
2844
|
top: 40,
|
|
1892
|
-
|
|
2845
|
+
left: 10,
|
|
1893
2846
|
zIndex: 30,
|
|
1894
2847
|
display: "flex",
|
|
1895
2848
|
alignItems: "center",
|
|
@@ -1901,14 +2854,15 @@ window.__ModuleLoader__.load({
|
|
|
1901
2854
|
borderRadius: 10,
|
|
1902
2855
|
cornerShape: "round",
|
|
1903
2856
|
boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
|
|
1904
|
-
maxWidth: "calc(100% -
|
|
1905
|
-
|
|
2857
|
+
maxWidth: "calc(100% - 20px)",
|
|
2858
|
+
// the button's center sits 12px inside the popup's left edge
|
|
2859
|
+
transformOrigin: "12px top"
|
|
1906
2860
|
},
|
|
1907
2861
|
children: [1, 2, 3, 4, 5, 6].map(function (lv) {
|
|
1908
2862
|
var active = !!levelSet[lv];
|
|
1909
2863
|
return react_jsx_runtime.jsx("button", {
|
|
1910
2864
|
onClick: function () { toggleLevel(lv); },
|
|
1911
|
-
title: active ? "
|
|
2865
|
+
title: active ? T("levels.hide") + lv : T("levels.show") + lv,
|
|
1912
2866
|
style: {
|
|
1913
2867
|
height: 22,
|
|
1914
2868
|
padding: "0 7px",
|
|
@@ -1920,9 +2874,9 @@ window.__ModuleLoader__.load({
|
|
|
1920
2874
|
lineHeight: "22px",
|
|
1921
2875
|
fontFamily: "inherit",
|
|
1922
2876
|
background: active
|
|
1923
|
-
?
|
|
2877
|
+
? C.chip
|
|
1924
2878
|
: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
|
|
1925
|
-
color: active ?
|
|
2879
|
+
color: active ? C.accent : C.muted,
|
|
1926
2880
|
fontWeight: active ? 600 : 400
|
|
1927
2881
|
},
|
|
1928
2882
|
children: "H" + lv
|
|
@@ -1952,7 +2906,7 @@ window.__ModuleLoader__.load({
|
|
|
1952
2906
|
? { right: viewport ? viewport.right + 52 : 60 } // clear of the milestone rail
|
|
1953
2907
|
: { left: viewport ? viewport.left : 0 })
|
|
1954
2908
|
},
|
|
1955
|
-
title: "
|
|
2909
|
+
title: T("handle.expand"),
|
|
1956
2910
|
onClick: function () { setOpen(true); },
|
|
1957
2911
|
onMouseEnter: function () { if (edgeRef.current) edgeRef.current.style.opacity = "1"; },
|
|
1958
2912
|
onMouseLeave: function () { if (edgeRef.current) edgeRef.current.style.opacity = "0.5"; },
|
|
@@ -1987,29 +2941,119 @@ window.__ModuleLoader__.load({
|
|
|
1987
2941
|
})
|
|
1988
2942
|
}) : null;
|
|
1989
2943
|
|
|
2944
|
+
// ---- hover preview card ------------------------------------------------
|
|
2945
|
+
// Rendered INSIDE the body portal (position: fixed) so the outline's own
|
|
2946
|
+
// scroll container can never clip it, and pointer-events: none so it can
|
|
2947
|
+
// never steal the hover that opened it.
|
|
2948
|
+
var hoverEl = null;
|
|
2949
|
+
if (hoverCard && open) {
|
|
2950
|
+
var cardW = 264;
|
|
2951
|
+
var cardTop = Math.max(8, Math.min(hoverCard.top - 4, window.innerHeight - 210));
|
|
2952
|
+
var cardLeft = dockRight
|
|
2953
|
+
? Math.max(8, hoverCard.left - cardW - 10)
|
|
2954
|
+
: Math.min(window.innerWidth - cardW - 8, hoverCard.right + 10);
|
|
2955
|
+
var cardBody = hoverCard.preview || hoverCard.sub || "";
|
|
2956
|
+
var cardChildren = [
|
|
2957
|
+
react_jsx_runtime.jsx("div", {
|
|
2958
|
+
style: { fontSize: 12, fontWeight: 600, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
2959
|
+
children: hoverCard.title
|
|
2960
|
+
}, "hc-t")
|
|
2961
|
+
];
|
|
2962
|
+
if (hoverCard.meta) {
|
|
2963
|
+
cardChildren.push(react_jsx_runtime.jsx("div", {
|
|
2964
|
+
style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
2965
|
+
children: hoverCard.meta
|
|
2966
|
+
}, "hc-m"));
|
|
2967
|
+
}
|
|
2968
|
+
cardChildren.push(react_jsx_runtime.jsx("div", {
|
|
2969
|
+
style: { fontSize: 11, lineHeight: "16px", color: C.text, opacity: 0.9, marginTop: 4, maxHeight: 116, overflow: "hidden" },
|
|
2970
|
+
children: cardBody || (hoverCard.ghost ? T("turn.loadTip") : "")
|
|
2971
|
+
}, "hc-b"));
|
|
2972
|
+
if (hoverCard.ghost) {
|
|
2973
|
+
cardChildren.push(react_jsx_runtime.jsx("div", {
|
|
2974
|
+
style: { fontSize: 10, color: C.muted, opacity: 0.7, marginTop: 4 },
|
|
2975
|
+
children: T("preview.truncated")
|
|
2976
|
+
}, "hc-n"));
|
|
2977
|
+
}
|
|
2978
|
+
hoverEl = react_jsx_runtime.jsx("div", {
|
|
2979
|
+
className: "dqt-hover" + (hoverClosing ? " dqt-hover-closing" : ""),
|
|
2980
|
+
style: {
|
|
2981
|
+
position: "fixed",
|
|
2982
|
+
top: cardTop,
|
|
2983
|
+
left: cardLeft,
|
|
2984
|
+
width: cardW,
|
|
2985
|
+
padding: "8px 10px",
|
|
2986
|
+
borderRadius: 8,
|
|
2987
|
+
cornerShape: "round",
|
|
2988
|
+
background: C.panelBg,
|
|
2989
|
+
color: C.text,
|
|
2990
|
+
border: "1px solid " + C.panelBorder,
|
|
2991
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.28)",
|
|
2992
|
+
zIndex: Z_BASE + 1,
|
|
2993
|
+
pointerEvents: "none"
|
|
2994
|
+
},
|
|
2995
|
+
children: cardChildren
|
|
2996
|
+
}, "hover-card");
|
|
2997
|
+
}
|
|
2998
|
+
|
|
1990
2999
|
return react_dom.createPortal(
|
|
1991
|
-
react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl] }),
|
|
3000
|
+
react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl, hoverEl] }),
|
|
1992
3001
|
document.body
|
|
1993
3002
|
);
|
|
1994
3003
|
}
|
|
1995
3004
|
|
|
1996
|
-
function renderItem(n, depth, jump, C, uid) {
|
|
3005
|
+
function renderItem(n, depth, jump, C, uid, extra) {
|
|
1997
3006
|
var hasChildren = !!(n.children && n.children.length);
|
|
1998
|
-
var
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
3007
|
+
var path = extra && extra.paths ? extra.paths[headingId(n)] : null;
|
|
3008
|
+
var hoverInfo = {
|
|
3009
|
+
title: n.title,
|
|
3010
|
+
sub: n.sub || "",
|
|
3011
|
+
preview: n.preview || "",
|
|
3012
|
+
meta: [n.time, path ? path.path : ""].filter(Boolean).join(" · "),
|
|
3013
|
+
ghost: false
|
|
3014
|
+
};
|
|
3015
|
+
var rows = [
|
|
3016
|
+
react_jsx_runtime.jsx("div", {
|
|
3017
|
+
style: { display: "flex", alignItems: "center", gap: 2, minWidth: 0 },
|
|
3018
|
+
children: react_jsx_runtime.jsx("span", {
|
|
3019
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
|
|
3020
|
+
children: n.title
|
|
3021
|
+
})
|
|
3022
|
+
}, uid + "-l")
|
|
3023
|
+
];
|
|
3024
|
+
// Subtitle = the section's first sentence. This is what makes two headings
|
|
3025
|
+
// with the SAME text distinguishable without hovering ("which one is this?").
|
|
3026
|
+
if (n.sub) {
|
|
3027
|
+
rows.push(react_jsx_runtime.jsx("div", {
|
|
3028
|
+
style: {
|
|
3029
|
+
fontSize: 10,
|
|
3030
|
+
lineHeight: "13px",
|
|
3031
|
+
color: C.muted,
|
|
3032
|
+
opacity: 0.75,
|
|
3033
|
+
whiteSpace: "nowrap",
|
|
3034
|
+
overflow: "hidden",
|
|
3035
|
+
textOverflow: "ellipsis"
|
|
3036
|
+
},
|
|
3037
|
+
children: n.sub
|
|
3038
|
+
}, uid + "-s"));
|
|
3039
|
+
}
|
|
2003
3040
|
return react_jsx_runtime.jsx(
|
|
2004
3041
|
"div",
|
|
2005
3042
|
{
|
|
2006
3043
|
onClick: function () { jump(n.key, n.idx); },
|
|
3044
|
+
onMouseEnter: function (e) {
|
|
3045
|
+
e.currentTarget.style.background = C.hover;
|
|
3046
|
+
if (extra && extra.hoverStart) extra.hoverStart(hoverInfo, e.currentTarget);
|
|
3047
|
+
},
|
|
3048
|
+
onMouseLeave: function (e) {
|
|
3049
|
+
e.currentTarget.style.background = "transparent";
|
|
3050
|
+
if (extra && extra.hoverEnd) extra.hoverEnd();
|
|
3051
|
+
},
|
|
2007
3052
|
"data-jump-key": n.key,
|
|
2008
3053
|
"data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
|
|
2009
3054
|
style: {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
gap: 2,
|
|
3055
|
+
// no fixed height: a row grows by one line when it has a subtitle
|
|
3056
|
+
display: "block",
|
|
2013
3057
|
padding: "2px 6px",
|
|
2014
3058
|
paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
|
|
2015
3059
|
margin: "1px 0",
|
|
@@ -2019,26 +3063,23 @@ window.__ModuleLoader__.load({
|
|
|
2019
3063
|
color: n.level <= 2 ? C.text : C.muted,
|
|
2020
3064
|
fontWeight: n.level <= 2 ? 600 : 400,
|
|
2021
3065
|
lineHeight: "18px",
|
|
2022
|
-
height: 22,
|
|
2023
3066
|
whiteSpace: "nowrap",
|
|
2024
3067
|
overflow: "hidden"
|
|
2025
3068
|
},
|
|
2026
|
-
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
2027
|
-
onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
|
|
2028
3069
|
title: n.title,
|
|
2029
|
-
children:
|
|
3070
|
+
children: rows
|
|
2030
3071
|
},
|
|
2031
3072
|
uid + "-" + n.level + "-" + (n.key || "")
|
|
2032
3073
|
);
|
|
2033
3074
|
}
|
|
2034
3075
|
|
|
2035
|
-
function renderNodes(nodes, depth, jump, C, uid) {
|
|
3076
|
+
function renderNodes(nodes, depth, jump, C, uid, extra) {
|
|
2036
3077
|
var out = [];
|
|
2037
3078
|
for (var i = 0; i < nodes.length; i++) {
|
|
2038
3079
|
var n = nodes[i];
|
|
2039
|
-
out.push(renderItem(n, depth, jump, C, uid + "-" + i));
|
|
3080
|
+
out.push(renderItem(n, depth, jump, C, uid + "-" + i, extra));
|
|
2040
3081
|
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));
|
|
3082
|
+
out.push(react_jsx_runtime.jsx("div", { children: renderNodes(n.children, depth + 1, jump, C, uid + "-" + i, extra) }, uid + "-c-" + i));
|
|
2042
3083
|
}
|
|
2043
3084
|
}
|
|
2044
3085
|
return out;
|
|
@@ -2048,8 +3089,8 @@ window.__ModuleLoader__.load({
|
|
|
2048
3089
|
// While a query is present the list shows every matched heading/message
|
|
2049
3090
|
// (grouped per message, with an occurrence count) instead of the outline, so
|
|
2050
3091
|
// search is "see them all, then jump" rather than stepping blindly.
|
|
2051
|
-
function hitSpans(text, q) {
|
|
2052
|
-
var parts = highlightParts(text, q);
|
|
3092
|
+
function hitSpans(text, q, fuzzy) {
|
|
3093
|
+
var parts = highlightParts(text, q, fuzzy);
|
|
2053
3094
|
return parts.map(function (p, i) {
|
|
2054
3095
|
return p.hit
|
|
2055
3096
|
? react_jsx_runtime.jsx("span", {
|
|
@@ -2060,8 +3101,11 @@ window.__ModuleLoader__.load({
|
|
|
2060
3101
|
});
|
|
2061
3102
|
}
|
|
2062
3103
|
|
|
2063
|
-
function renderResultRow(r, i, q, C, onRow, isActive) {
|
|
3104
|
+
function renderResultRow(r, i, q, C, onRow, isActive, extra) {
|
|
3105
|
+
var fuzzy = !!(extra && extra.fuzzy);
|
|
2064
3106
|
var meta = [];
|
|
3107
|
+
if (r.ghost) meta.push(T("turn.unloaded"));
|
|
3108
|
+
if (r.failed) meta.push(T("turn.failed"));
|
|
2065
3109
|
if (r.time) meta.push(r.time);
|
|
2066
3110
|
if (r.path) meta.push(r.path);
|
|
2067
3111
|
if (r.count > 1) meta.push("×" + r.count);
|
|
@@ -2075,7 +3119,7 @@ window.__ModuleLoader__.load({
|
|
|
2075
3119
|
overflow: "hidden",
|
|
2076
3120
|
textOverflow: "ellipsis"
|
|
2077
3121
|
},
|
|
2078
|
-
children: hitSpans(r.title, q)
|
|
3122
|
+
children: hitSpans(r.title, q, fuzzy)
|
|
2079
3123
|
}, "t" + i)
|
|
2080
3124
|
];
|
|
2081
3125
|
if (r.snippet) {
|
|
@@ -2088,7 +3132,7 @@ window.__ModuleLoader__.load({
|
|
|
2088
3132
|
overflow: "hidden",
|
|
2089
3133
|
textOverflow: "ellipsis"
|
|
2090
3134
|
},
|
|
2091
|
-
children: hitSpans(r.snippet, q)
|
|
3135
|
+
children: hitSpans(r.snippet, q, fuzzy)
|
|
2092
3136
|
}, "s" + i));
|
|
2093
3137
|
}
|
|
2094
3138
|
if (meta.length) {
|
|
@@ -2102,9 +3146,23 @@ window.__ModuleLoader__.load({
|
|
|
2102
3146
|
// keyword in the conversation and scrolls there, exactly like Enter
|
|
2103
3147
|
// stepping — a row that only scrolled (no highlight) was the old bug
|
|
2104
3148
|
onClick: function () { onRow(i); },
|
|
3149
|
+
onMouseEnter: function (e) {
|
|
3150
|
+
e.currentTarget.style.background = C.hover;
|
|
3151
|
+
if (extra && extra.hoverStart) {
|
|
3152
|
+
extra.hoverStart({
|
|
3153
|
+
title: r.title,
|
|
3154
|
+
sub: r.snippet || "",
|
|
3155
|
+
preview: r.snippet || "",
|
|
3156
|
+
meta: meta.join(" · "),
|
|
3157
|
+
ghost: !!r.ghost
|
|
3158
|
+
}, e.currentTarget);
|
|
3159
|
+
}
|
|
3160
|
+
},
|
|
3161
|
+
onMouseLeave: function (e) {
|
|
3162
|
+
e.currentTarget.style.background = isActive ? C.chip : "transparent";
|
|
3163
|
+
if (extra && extra.hoverEnd) extra.hoverEnd();
|
|
3164
|
+
},
|
|
2105
3165
|
"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
3166
|
title: r.path || r.title,
|
|
2109
3167
|
style: {
|
|
2110
3168
|
padding: "4px 6px",
|
|
@@ -2112,23 +3170,23 @@ window.__ModuleLoader__.load({
|
|
|
2112
3170
|
borderRadius: 6,
|
|
2113
3171
|
cursor: "pointer",
|
|
2114
3172
|
minWidth: 0,
|
|
2115
|
-
background: isActive ?
|
|
3173
|
+
background: isActive ? C.chip : "transparent",
|
|
2116
3174
|
transition: "background 0.15s ease"
|
|
2117
3175
|
},
|
|
2118
3176
|
children: children
|
|
2119
3177
|
}, "res-" + i);
|
|
2120
3178
|
}
|
|
2121
3179
|
|
|
2122
|
-
function renderResults(rows, q, C, onRow, activeRow) {
|
|
3180
|
+
function renderResults(rows, q, C, onRow, activeRow, extra) {
|
|
2123
3181
|
if (!rows || rows.length === 0) {
|
|
2124
3182
|
return react_jsx_runtime.jsx("div", {
|
|
2125
3183
|
style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
|
|
2126
|
-
children: "
|
|
2127
|
-
});
|
|
3184
|
+
children: T("search.empty")
|
|
3185
|
+
}, "empty");
|
|
2128
3186
|
}
|
|
2129
3187
|
var out = [];
|
|
2130
3188
|
for (var i = 0; i < rows.length; i++) {
|
|
2131
|
-
out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
|
|
3189
|
+
out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow, extra));
|
|
2132
3190
|
}
|
|
2133
3191
|
return out;
|
|
2134
3192
|
}
|
|
@@ -2139,9 +3197,10 @@ window.__ModuleLoader__.load({
|
|
|
2139
3197
|
// Implemented as a function (not an inline closure in a loop) so each
|
|
2140
3198
|
// header captures its own (g, gi) — the var-in-loop closure bug would
|
|
2141
3199
|
// otherwise make every header jump to the last group.
|
|
2142
|
-
function renderGroupHeader(g, gi, jump, C) {
|
|
2143
|
-
|
|
2144
|
-
|
|
3200
|
+
function renderGroupHeader(g, gi, jump, C, isActive) {
|
|
3201
|
+
// a turn that ended in a failure has no reply to land on: its terminal row IS
|
|
3202
|
+
// the interesting position, so the header lands there (and says so in the tip)
|
|
3203
|
+
var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : (g.failure ? g.failure.key : "");
|
|
2145
3204
|
var jumpToTurn = function (e) {
|
|
2146
3205
|
e.stopPropagation();
|
|
2147
3206
|
// prefer the model's reply; fall back to the user message, then to the
|
|
@@ -2152,30 +3211,58 @@ window.__ModuleLoader__.load({
|
|
|
2152
3211
|
};
|
|
2153
3212
|
return react_jsx_runtime.jsx("div", {
|
|
2154
3213
|
style: {
|
|
3214
|
+
// ONE row geometry for every group header, whether or not the turn has
|
|
3215
|
+
// headings: same box, same 18px rhythm, same left inset, so the time
|
|
3216
|
+
// column of a heading-less turn lines up with all the others. Only the
|
|
3217
|
+
// type emphasis differs (weight + colour, see the label below).
|
|
2155
3218
|
padding: "1px 4px 2px",
|
|
2156
3219
|
height: 18,
|
|
2157
3220
|
display: "flex",
|
|
2158
3221
|
alignItems: "center",
|
|
2159
3222
|
minWidth: 0,
|
|
2160
3223
|
// sticky: while you scroll the outline, the header of the group you are
|
|
2161
|
-
// inside stays pinned at the top
|
|
2162
|
-
// the
|
|
3224
|
+
// inside stays pinned at the top. It must be opaque to cover the rows
|
|
3225
|
+
// scrolling underneath, so its base colour is the panel background in both
|
|
3226
|
+
// states — the "current group" tint rides on its own layer inside, which
|
|
3227
|
+
// can FADE (a background-image gradient cannot be transitioned).
|
|
2163
3228
|
position: "sticky",
|
|
2164
3229
|
top: 0,
|
|
2165
3230
|
zIndex: 2,
|
|
2166
3231
|
background: C.panelBg
|
|
2167
3232
|
},
|
|
2168
|
-
children:
|
|
3233
|
+
children: [
|
|
3234
|
+
react_jsx_runtime.jsx("div", {
|
|
3235
|
+
style: {
|
|
3236
|
+
position: "absolute",
|
|
3237
|
+
left: 0,
|
|
3238
|
+
right: 0,
|
|
3239
|
+
top: 0,
|
|
3240
|
+
bottom: 0,
|
|
3241
|
+
background: C.groupTint,
|
|
3242
|
+
opacity: isActive ? 1 : 0,
|
|
3243
|
+
transition: "opacity 0.18s ease",
|
|
3244
|
+
pointerEvents: "none"
|
|
3245
|
+
}
|
|
3246
|
+
}, "g-h-tint-" + gi),
|
|
3247
|
+
react_jsx_runtime.jsx("span", {
|
|
2169
3248
|
onClick: jumpToTurn,
|
|
2170
|
-
title:
|
|
3249
|
+
title: (g.msgs && g.msgs.length > 0) ? T("turn.jumpReply") : (g.failure ? T("turn.jumpFailed") : T("turn.jumpTurn")),
|
|
2171
3250
|
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
2172
3251
|
onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
|
|
2173
3252
|
style: {
|
|
3253
|
+
// ONE style for every group header, heading-less turns included: a turn
|
|
3254
|
+
// whose reply has no markdown headings is not a different kind of entry,
|
|
3255
|
+
// it is simply a group with no rows under its header, and it must line up
|
|
3256
|
+
// with the rest of the list (same size, weight, colour, opacity). The only
|
|
3257
|
+
// thing that marks a group is the shared "current group" tint, which lives
|
|
3258
|
+
// on the group box — never on the label.
|
|
2174
3259
|
fontSize: 11,
|
|
3260
|
+
fontWeight: 400,
|
|
2175
3261
|
color: C.muted,
|
|
2176
3262
|
cursor: "pointer",
|
|
2177
3263
|
padding: "1px 5px",
|
|
2178
3264
|
borderRadius: 4,
|
|
3265
|
+
background: "transparent",
|
|
2179
3266
|
transition: "background 0.15s ease",
|
|
2180
3267
|
display: "inline-flex",
|
|
2181
3268
|
alignItems: "center",
|
|
@@ -2187,18 +3274,130 @@ window.__ModuleLoader__.load({
|
|
|
2187
3274
|
children: [
|
|
2188
3275
|
react_jsx_runtime.jsx("span", { style: { fontWeight: 600, flex: "none" }, children: g.time || " " }),
|
|
2189
3276
|
g.userText ? react_jsx_runtime.jsx("span", {
|
|
2190
|
-
style: {
|
|
3277
|
+
style: {
|
|
3278
|
+
fontWeight: 400,
|
|
3279
|
+
opacity: 0.75,
|
|
3280
|
+
overflow: "hidden",
|
|
3281
|
+
textOverflow: "ellipsis",
|
|
3282
|
+
whiteSpace: "nowrap",
|
|
3283
|
+
minWidth: 0
|
|
3284
|
+
},
|
|
2191
3285
|
children: g.userText
|
|
2192
3286
|
}) : null
|
|
2193
3287
|
]
|
|
2194
3288
|
})
|
|
3289
|
+
]
|
|
2195
3290
|
}, "g-h-" + gi);
|
|
2196
3291
|
}
|
|
2197
3292
|
|
|
3293
|
+
// A turn the paged event window has not loaded yet: the host outline still
|
|
3294
|
+
// names it (number + bounded previews), so the outline covers the whole
|
|
3295
|
+
// session. Clicking pages that turn in through the session's jump loader.
|
|
3296
|
+
function renderGhostGroup(g, gi, C, extra) {
|
|
3297
|
+
extra = extra || {};
|
|
3298
|
+
var children = [
|
|
3299
|
+
react_jsx_runtime.jsx("div", {
|
|
3300
|
+
style: { display: "flex", alignItems: "center", gap: 5, minWidth: 0 },
|
|
3301
|
+
children: [
|
|
3302
|
+
react_jsx_runtime.jsx("span", {
|
|
3303
|
+
style: { fontSize: 10, fontWeight: 600, flex: "none", color: C.muted, opacity: 0.8, border: "1px solid " + C.panelBorder, borderRadius: 4, padding: "0 4px" },
|
|
3304
|
+
children: T("turn.unloaded")
|
|
3305
|
+
}),
|
|
3306
|
+
react_jsx_runtime.jsx("span", {
|
|
3307
|
+
style: { fontSize: 11, color: C.muted, fontWeight: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
|
|
3308
|
+
children: g.userText || "#" + g.turn
|
|
3309
|
+
})
|
|
3310
|
+
]
|
|
3311
|
+
})
|
|
3312
|
+
];
|
|
3313
|
+
if (g.preview) {
|
|
3314
|
+
children.push(react_jsx_runtime.jsx("div", {
|
|
3315
|
+
style: { fontSize: 10, lineHeight: "13px", color: C.muted, opacity: 0.65, marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" },
|
|
3316
|
+
children: g.preview
|
|
3317
|
+
}));
|
|
3318
|
+
}
|
|
3319
|
+
return react_jsx_runtime.jsx("div", {
|
|
3320
|
+
onClick: function () { extra.onOpenTurn(g.turn, g.seq); },
|
|
3321
|
+
onMouseEnter: function (e) {
|
|
3322
|
+
e.currentTarget.style.background = C.hover;
|
|
3323
|
+
e.currentTarget.style.opacity = "1";
|
|
3324
|
+
extra.hoverStart({ title: g.userText || "#" + g.turn, sub: "", preview: g.preview || "", meta: T("turn.unloaded"), ghost: true }, e.currentTarget);
|
|
3325
|
+
},
|
|
3326
|
+
onMouseLeave: function (e) {
|
|
3327
|
+
e.currentTarget.style.background = "transparent";
|
|
3328
|
+
e.currentTarget.style.opacity = "0.85";
|
|
3329
|
+
extra.hoverEnd();
|
|
3330
|
+
},
|
|
3331
|
+
"data-group-idx": gi,
|
|
3332
|
+
"data-ghost-turn": g.turn,
|
|
3333
|
+
title: T("turn.loadTip"),
|
|
3334
|
+
style: {
|
|
3335
|
+
padding: "3px 6px",
|
|
3336
|
+
margin: "1px 0",
|
|
3337
|
+
borderRadius: 6,
|
|
3338
|
+
cursor: "pointer",
|
|
3339
|
+
minWidth: 0,
|
|
3340
|
+
opacity: 0.85,
|
|
3341
|
+
transition: "background 0.15s ease, opacity 0.15s ease"
|
|
3342
|
+
},
|
|
3343
|
+
children: children
|
|
3344
|
+
}, "g-ghost-" + gi);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// A turn that ended in a terminal failure (the host's `turn-error` row): the
|
|
3348
|
+
// outline lists it as a normal turn and adds this row, so a turn whose request
|
|
3349
|
+
// never came back reads as "请求失败 · <provider message>" instead of "未加载"
|
|
3350
|
+
// and never silently disappears from the outline.
|
|
3351
|
+
function renderFailureRow(g, gi, jump, C) {
|
|
3352
|
+
var f = g.failure;
|
|
3353
|
+
var message = f.message || f.code || "";
|
|
3354
|
+
var open = function (e) {
|
|
3355
|
+
e.stopPropagation();
|
|
3356
|
+
if (findRow(f.key)) jump(f.key);
|
|
3357
|
+
else if (g.userKey && findRow(g.userKey)) jump(g.userKey);
|
|
3358
|
+
};
|
|
3359
|
+
return react_jsx_runtime.jsx("div", {
|
|
3360
|
+
onClick: open,
|
|
3361
|
+
title: message || T("turn.failed"),
|
|
3362
|
+
"data-turn-failure": g.turn === null || g.turn === undefined ? String(gi) : String(g.turn),
|
|
3363
|
+
style: {
|
|
3364
|
+
display: "flex",
|
|
3365
|
+
alignItems: "center",
|
|
3366
|
+
gap: 5,
|
|
3367
|
+
minWidth: 0,
|
|
3368
|
+
padding: "1px 6px",
|
|
3369
|
+
margin: "1px 0",
|
|
3370
|
+
borderRadius: 6,
|
|
3371
|
+
cursor: "pointer",
|
|
3372
|
+
transition: "background 0.15s ease"
|
|
3373
|
+
},
|
|
3374
|
+
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
3375
|
+
onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
|
|
3376
|
+
children: [
|
|
3377
|
+
react_jsx_runtime.jsx("span", {
|
|
3378
|
+
style: { fontSize: 10, fontWeight: 600, flex: "none", color: C.error, border: "1px solid " + C.error, borderRadius: 4, padding: "0 4px" },
|
|
3379
|
+
children: T("turn.failed")
|
|
3380
|
+
}),
|
|
3381
|
+
message ? react_jsx_runtime.jsx("span", {
|
|
3382
|
+
style: {
|
|
3383
|
+
fontSize: 11,
|
|
3384
|
+
color: C.muted,
|
|
3385
|
+
overflow: "hidden",
|
|
3386
|
+
textOverflow: "ellipsis",
|
|
3387
|
+
whiteSpace: "nowrap",
|
|
3388
|
+
minWidth: 0
|
|
3389
|
+
},
|
|
3390
|
+
children: message
|
|
3391
|
+
}) : null
|
|
3392
|
+
]
|
|
3393
|
+
}, "g-fail-" + gi);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
2198
3396
|
// render each conversation turn as its own block: solid divider + time header
|
|
2199
3397
|
// offset = global group index of the first rendered group (stable React keys)
|
|
2200
3398
|
// activeIdx = array of groups being read (full opacity); others are dimmed
|
|
2201
|
-
function renderGroups(groups, trees, jump, C, offset, activeIdx) {
|
|
3399
|
+
function renderGroups(groups, trees, jump, C, offset, activeIdx, extra) {
|
|
3400
|
+
extra = extra || {};
|
|
2202
3401
|
var base = offset || 0;
|
|
2203
3402
|
var activeSet = {};
|
|
2204
3403
|
if (Array.isArray(activeIdx)) {
|
|
@@ -2208,24 +3407,61 @@ window.__ModuleLoader__.load({
|
|
|
2208
3407
|
for (var i = 0; i < groups.length; i++) {
|
|
2209
3408
|
var g = groups[i];
|
|
2210
3409
|
var gi = base + i;
|
|
2211
|
-
var
|
|
2212
|
-
|
|
3410
|
+
var isActive = !!activeSet[gi];
|
|
3411
|
+
var rows = g.ghost ? [] : trees[i];
|
|
3412
|
+
// The divider between two groups stays OUTSIDE the highlight box: inside it
|
|
3413
|
+
// would draw a rule straight across the tinted block.
|
|
3414
|
+
out.push(react_jsx_runtime.jsx("div", {
|
|
2213
3415
|
style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
|
|
2214
3416
|
}, "g-sep-" + gi));
|
|
2215
|
-
items
|
|
2216
|
-
|
|
2217
|
-
|
|
3417
|
+
var items = [];
|
|
3418
|
+
if (g.ghost) {
|
|
3419
|
+
items.push(renderGhostGroup(g, gi, C, extra));
|
|
3420
|
+
} else {
|
|
3421
|
+
// every group gets the SAME header, whether or not this turn produced any
|
|
3422
|
+
// markdown headings — a heading-less turn is just a group with no rows, not
|
|
3423
|
+
// a second kind of entry (`rows` only decides what gets listed below it)
|
|
3424
|
+
items.push(renderGroupHeader(g, gi, jump, C, isActive));
|
|
3425
|
+
items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
|
|
3426
|
+
// the failure is the turn's LAST event, so its row sits under the rows
|
|
3427
|
+
if (g.failure) items.push(renderFailureRow(g, gi, jump, C));
|
|
3428
|
+
}
|
|
3429
|
+
// The group being read stays at full opacity and every other group is dimmed
|
|
3430
|
+
// to 0.85 — enough to mark the reading position without turning the list grey
|
|
3431
|
+
// (0.6 did, and multiplied by the panel's own idle transparency it made the
|
|
3432
|
+
// outline barely readable). The current group ALSO gets the closed tinted box
|
|
3433
|
+
// below (fill + outline + accent bar), which stays visible on top of any
|
|
3434
|
+
// panel transparency and never looks cut off on the right.
|
|
3435
|
+
var dim = !isActive && !g.ghost;
|
|
2218
3436
|
out.push(react_jsx_runtime.jsx("div", {
|
|
2219
3437
|
"data-group-idx": gi,
|
|
2220
|
-
|
|
3438
|
+
"data-active-group": isActive ? "on" : "off",
|
|
3439
|
+
style: {
|
|
3440
|
+
opacity: dim ? 0.85 : 1,
|
|
3441
|
+
// the box fades in AND out: colour transitions only, and the left accent
|
|
3442
|
+
// keeps its 3px width in both states so activating a group never reflows
|
|
3443
|
+
// (a width change would shift every row by 2px)
|
|
3444
|
+
transition: "opacity 0.15s ease, background-color 0.18s ease, border-color 0.18s ease",
|
|
3445
|
+
backgroundColor: isActive ? C.groupTint : "transparent",
|
|
3446
|
+
borderTop: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
|
|
3447
|
+
borderRight: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
|
|
3448
|
+
borderBottom: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
|
|
3449
|
+
borderLeft: "3px solid " + (isActive ? C.groupEdge : "transparent"),
|
|
3450
|
+
borderRadius: 6,
|
|
3451
|
+
margin: "2px -5px",
|
|
3452
|
+
paddingLeft: 2
|
|
3453
|
+
},
|
|
2221
3454
|
children: items
|
|
2222
3455
|
}, "g-" + gi));
|
|
2223
3456
|
}
|
|
2224
3457
|
return out;
|
|
2225
3458
|
}
|
|
2226
3459
|
|
|
3460
|
+
// Host-facing locale namespace: the slot declares `locale: "dsh-quick-toc"`,
|
|
3461
|
+
// which is what hands the panel its `t` prop. The panel's own visible strings
|
|
3462
|
+
// live in DICTS and are kept in sync with this table.
|
|
2227
3463
|
var zh = {
|
|
2228
|
-
"panel.title": "
|
|
3464
|
+
"panel.title": DICTS.zh["panel.title"]
|
|
2229
3465
|
};
|
|
2230
3466
|
var en = {
|
|
2231
3467
|
"panel.title": "Conversation Outline"
|
|
@@ -2237,6 +3473,29 @@ window.__ModuleLoader__.load({
|
|
|
2237
3473
|
ctx.effect(function () {
|
|
2238
3474
|
return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
|
|
2239
3475
|
}, "dsh-quick-toc: dictionaries");
|
|
3476
|
+
|
|
3477
|
+
// Bridge to the host session face, used to open a turn whose events the
|
|
3478
|
+
// paged window has not loaded: ctx.sessions.binding(id).session.loadThrough(seq)
|
|
3479
|
+
// is the host's documented "page history backwards until the window covers
|
|
3480
|
+
// seq" jump loader.
|
|
3481
|
+
//
|
|
3482
|
+
// The client plugin facade offers two ways in: a direct `ctx.sessions`
|
|
3483
|
+
// property read (which demands `inject: ["sessions"]` on the returned plugin
|
|
3484
|
+
// object, and the runtime then PARKS the whole package whenever that provider
|
|
3485
|
+
// is absent) and `ctx.get(name)`, the declaration-free lookup. This uses the
|
|
3486
|
+
// lookup — deliberately optional, so a host without the session controller
|
|
3487
|
+
// still gets a working outline, and callers report the missing loader
|
|
3488
|
+
// instead of the panel disappearing.
|
|
3489
|
+
var host = {
|
|
3490
|
+
sessions: function () {
|
|
3491
|
+
try {
|
|
3492
|
+
return typeof ctx.get === "function" ? (ctx.get("sessions") || null) : null;
|
|
3493
|
+
} catch (e) {
|
|
3494
|
+
return null;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
|
|
2240
3499
|
// Recent DSH (0.1.5-rc.1): session-scoped hooks (useChat/useSession/sessionId) only arrive
|
|
2241
3500
|
// inside a declared session slot. Register the panel into the session-scoped
|
|
2242
3501
|
// conversation.input.overlay (list/additive) so it receives useChat; the panel
|
|
@@ -2248,7 +3507,10 @@ window.__ModuleLoader__.load({
|
|
|
2248
3507
|
id: "quick-toc",
|
|
2249
3508
|
order: 90,
|
|
2250
3509
|
locale: "dsh-quick-toc"
|
|
2251
|
-
},
|
|
3510
|
+
}, function OutlinePanelWithHost(props) {
|
|
3511
|
+
// the panel needs the host bridge plus the session id it was registered for
|
|
3512
|
+
return OutlinePanel(Object.assign({}, props, { tocHost: host }));
|
|
3513
|
+
});
|
|
2252
3514
|
});
|
|
2253
3515
|
}
|
|
2254
3516
|
|
|
@@ -2267,6 +3529,15 @@ window.__ModuleLoader__.load({
|
|
|
2267
3529
|
// the level picker's enter/exit animations live here as classes so React
|
|
2268
3530
|
// re-renders (which rewrite inline styles) cannot restart or cancel them
|
|
2269
3531
|
".dqt-levels-pop{animation:dqt-pop-in 0.22s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
|
|
3532
|
+
// hover preview card: fades in place, and fades OUT when the pointer
|
|
3533
|
+
// leaves (both as stylesheet classes so re-renders cannot cancel them)
|
|
3534
|
+
".dqt-hover{animation:dqt-fade-in 0.12s linear both}" +
|
|
3535
|
+
".dqt-hover-closing{animation:dqt-fade-out 0.16s cubic-bezier(0.2, 0.9, 0.3, 1) both}" +
|
|
3536
|
+
// transient edge banner over the bottom of the outline list
|
|
3537
|
+
"@keyframes dqt-toast-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}" +
|
|
3538
|
+
"@keyframes dqt-toast-out{from{opacity:1;transform:none}to{opacity:0;transform:none}}" +
|
|
3539
|
+
".dqt-toast{animation:dqt-toast-in 0.2s cubic-bezier(0.22, 0.9, 0.3, 1) both}" +
|
|
3540
|
+
".dqt-toast-closing{animation:dqt-toast-out 0.42s linear both}" +
|
|
2270
3541
|
// exit: starts moving immediately (fast attack), so the dismiss feels snappy
|
|
2271
3542
|
".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
3543
|
// Hide the native scrollbar entirely (no arrow buttons, no grey bar) —
|