dsh-quick-toc 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -13,13 +13,65 @@ window.__ModuleLoader__.load({
13
13
 
14
14
  // ---------- constants ----------
15
15
  var PANEL_WIDTH = 288;
16
- // Very high base so the portal-rendered panel (top-level stacking context)
17
- // always sits above DSH content and its transcript width handles.
18
- // Panel layer: above the app's popovers (z 100) and the transcript width
19
- // handles (z 8), but BELOW DSH's modal layer (settings/image viewer, z 1000)
20
- // so opening settings covers the panel instead of the panel floating on top.
21
- var Z_BASE = 500;
16
+ // ---------- layers ----------
17
+ // This plugin floats over the conversation, so it belongs in the ONE free band
18
+ // of the stacking order: above the app's own content, below everything that
19
+ // claims to be an overlay. Outranking other overlays is not a neutral choice —
20
+ // the previous flat z 500 painted the handles and the panel over other plugins'
21
+ // dialogs, and anything sitting on a dialog's close button makes that dialog
22
+ // impossible to dismiss.
23
+ //
24
+ // Measured neighbours (dsh-web-frontend/dist CSS plus every @deepseek-ai
25
+ // dsh-client-ui-* bundle and the installed plugins — values, not guesses):
26
+ // 0-6 trajectory view internals
27
+ // 7-10 conversation: sticky composer seat 7, transcript width grips 8
28
+ // ([data-width-handle], width cap 40px, visible only on hover),
29
+ // composer with a trigger menu 9, workspace row 10
30
+ // 11 layout frame
31
+ // 20 layout overlay layer (pointer-events:none, holds floating chrome)
32
+ // 25 dsh-better-sidebar panel host (its panels live inside it)
33
+ // 30 dsh-client-ui-cordis panel
34
+ // 40/60 right sidebar fullscreen panel / float host
35
+ // 100 menus and popovers (six packages)
36
+ // 200 dsh-context modal mask + card (.lc-modal-backdrop, .lc-ov-backdrop)
37
+ // 1000 DSH modals (settings, image viewer)
38
+ // 1100 tooltips and the app's top overlays
39
+ // So the whole band from 12 to 18 is free, and it is exactly where a reading
40
+ // aid belongs: above the transcript (including the width grips, which would
41
+ // otherwise take the pointer over our left edge) and below everything that is
42
+ // an overlay, the app's own floating chrome included.
43
+ var Z_HANDLE = 12; // the two edge handles
44
+ var Z_PANEL = 14; // the docked panel
45
+ var Z_SHEET = 16; // the curtain, which replaces the docked panel while it is up
46
+ var Z_HOVER = 18; // hover preview card: above both, still inside the free band
47
+ var Z_ERROR = 32; // crash banner: above our layers and the app's chrome, below
48
+ // DSH's popovers (100) and modals (1000)
22
49
  var EASE = "cubic-bezier(0.22, 0.9, 0.3, 1)"; // smooth non-linear slide
50
+ // How long the docked panel takes to get out of the curtain's way. It is the
51
+ // panel's own collapse duration (0.28s) plus a hair, so the curtain's drop only
52
+ // starts once the panel has actually finished travelling.
53
+ var PANEL_TUCK_MS = 300;
54
+ // The row-end control ("jump to the end of this section") lives in the last 24px
55
+ // of a row (a 20px button inset 4px). Every box a row can paint — the hover
56
+ // highlight, a group header's hover label, the right-aligned context text — stops
57
+ // END_ZONE from the row's right edge, so such a box can never touch the round
58
+ // backdrop the button paints on hover, let alone overlap it: the reader saw the
59
+ // two "fight" on long group headers, the label's box running right up to the
60
+ // circle.
61
+ var END_ZONE = 32;
62
+ // How long the collapsed edge handle takes to slip back into the edge when it is
63
+ // the thing that was clicked. The panel's own slide waits exactly this long, so the
64
+ // two motions are sequential instead of crossing over.
65
+ var EDGE_OUT_MS = 170;
66
+ // Same idea for the curtain handle: it is pressed flat and held for a beat before it
67
+ // leaves the tree, otherwise the flatten had no frame to play on (the curtain used
68
+ // to open and unmount it in the same commit). It covers the whole 200ms press — the
69
+ // press is deliberately slow-then-fast (see the element), so the curtain must not
70
+ // start before it has finished.
71
+ var DROP_OUT_MS = 220;
72
+ // Width of the curtain's search column: it is pushed in from the right edge and
73
+ // pushes the outline left by exactly this much while a search is up.
74
+ var SHEET_SEARCH_W = 360;
23
75
 
24
76
  // ---------- UI strings ----------
25
77
  // Every string the panel renders goes through T(). 0.5.0 ships the Chinese
@@ -35,16 +87,41 @@ window.__ModuleLoader__.load({
35
87
  "handle.dockRight": "移到右侧",
36
88
  "handle.collapse": "收起",
37
89
  "handle.expand": "展开大纲",
90
+ "a11y.list": "大纲列表:上下键移动,回车跳转,Esc 收起面板",
91
+ "a11y.hits": "第 {n} 个命中,共 {total} 个",
92
+ "a11y.results": "共 {n} 条结果",
93
+ "a11y.loading": "正在检索",
94
+ "a11y.crossEmpty": "没有结果",
95
+ "sheet.open": "全屏大纲(从顶部展开一层幕布)",
96
+ "sheet.openHandle": "展开幕布:全宽大纲从顶部落下",
97
+ "sheet.close": "收起幕布",
98
+ "sheet.noDrag": "幕布铺满对话区,不能拖动尺寸或位置",
38
99
  "levels.tip": "标题层级筛选",
39
100
  "levels.show": "显示 H",
40
101
  "levels.hide": "隐藏 H",
102
+ "levels.offInQuestions": "只看提问时没有标题行,层级筛选不生效",
103
+ "view.questions.tipOn": "当前:只看提问(每轮只显示时间与你的提问)。点击回到完整大纲",
104
+ "view.questions.tipOff": "当前:完整大纲。点击只看提问(每轮只显示时间与你的提问)",
41
105
  "search.open": "搜索标题",
42
106
  "search.word": "搜索",
43
107
  "search.tail": ",回车定位…",
44
108
  "search.scope.title": "标题",
45
109
  "search.scope.full": "全文",
110
+ "search.scope.cross": "会话",
46
111
  "search.scope.tipTitle": "当前:仅搜索标题。点击切换为全文搜索",
47
- "search.scope.tipFull": "当前:全文搜索。点击切换为仅标题",
112
+ "search.scope.tipFull": "当前:全文搜索。点击切换为跨会话检索",
113
+ "search.scope.tipCross": "当前:跨会话检索(用宿主的全文索引搜所有会话的消息正文)。点击回到仅搜索标题",
114
+ "search.cross.loading": "检索中…",
115
+ "search.cross.empty": "其他会话里没有匹配",
116
+ "search.cross.error": "检索失败:",
117
+ "search.cross.unavailable": "宿主没有提供跨会话检索接口",
118
+ "search.cross.disabled": "宿主没开启会话全文索引(session-query 的 openAt 被设成 never),跨会话检索暂时用不了",
119
+ "search.cross.more": "宿主一次最多返回 20 条,可能还有更多",
120
+ "search.cross.note": "跨会话检索走宿主的全文索引,只搜消息正文,模糊开关对它不生效",
121
+ "search.cross.hidden": "另有 {n} 条命中属于已归档、子会话或当前不在列表里的会话,已略过(这些会话现在打不开)",
122
+ "search.cross.goneHit": "这条命中所在的会话现在打不开(已归档或不在会话列表里),已跳过",
123
+ "search.cross.switched": "已切换到该会话,同关键词已开始在本会话内查找",
124
+ "search.cross.noSpot": "该会话已打开,但这处位置还没加载,没能跳过去;可用搜索框继续查找",
48
125
  "search.fuzzy": "模糊",
49
126
  "search.fuzzy.tipOn": "模糊匹配已开启:允许关键字中间夹少量其他文字,命中更多",
50
127
  "search.fuzzy.tipOff": "模糊匹配已关闭:只匹配连续的文字。点击开启",
@@ -54,9 +131,12 @@ window.__ModuleLoader__.load({
54
131
  "hint.bottom": "已经到底了",
55
132
  "outline.bottom": "回到底部",
56
133
  "turn.jumpReply": "跳转到该回合的模型回答开头",
134
+ "row.end": "跳到本节末尾",
57
135
  "turn.jumpTurn": "跳转到该回合开头",
58
136
  "turn.unloaded": "未加载",
59
137
  "turn.loadTip": "点击加载这个回合并跳转过去",
138
+ "turn.noMoreHistory": "宿主说这个会话没有更早的历史可翻了(它的翻页窗口已到开头),这一回合加载不出来",
139
+ "turn.loadFailed": "这个回合没能加载出来(这段历史可能已经不在会话日志里)",
60
140
  "turn.failed": "请求失败",
61
141
  "turn.jumpFailed": "跳转到该回合的报错位置",
62
142
  "time.yesterday": "昨天",
@@ -83,6 +163,8 @@ window.__ModuleLoader__.load({
83
163
  "settings.off": "关闭",
84
164
  "settings.fuzzy": "模糊搜索",
85
165
  "settings.hover": "悬停预览卡片",
166
+ "settings.remember": "记住阅读位置",
167
+ "settings.remember.tip": "开启后,重新打开某个会话会回到上次读到的那一轮;位置只保存在这台浏览器里",
86
168
  "settings.debug": "在控制台打印诊断日志",
87
169
  "settings.debug.tip": "开启后控制台会打印一行挂载诊断(宿主能力与跳转加载器状态)",
88
170
  "settings.overridden": "已自定义",
@@ -97,16 +179,41 @@ window.__ModuleLoader__.load({
97
179
  "handle.dockRight": "Move to the right",
98
180
  "handle.collapse": "Collapse",
99
181
  "handle.expand": "Expand the outline",
182
+ "a11y.list": "Outline list: arrow keys move, Enter jumps, Esc collapses the panel",
183
+ "a11y.hits": "Match {n} of {total}",
184
+ "a11y.results": "{n} result(s)",
185
+ "a11y.loading": "Searching",
186
+ "a11y.crossEmpty": "No results",
187
+ "sheet.open": "Full-width outline (a sheet that drops from the top)",
188
+ "sheet.openHandle": "Open the curtain: a full-width outline drops from the top",
189
+ "sheet.close": "Close the sheet",
190
+ "sheet.noDrag": "The sheet spans the conversation area, so it cannot be dragged or resized",
100
191
  "levels.tip": "Heading level filter",
101
192
  "levels.show": "Show H",
102
193
  "levels.hide": "Hide H",
194
+ "levels.offInQuestions": "Questions-only view has no heading rows, so the level filter does not apply",
195
+ "view.questions.tipOn": "Questions only: each turn shows just its time and your prompt. Click for the full outline",
196
+ "view.questions.tipOff": "Full outline. Click to show each turn's time and your prompt only",
103
197
  "search.open": "Search headings",
104
198
  "search.word": "Search",
105
199
  "search.tail": ", Enter to jump…",
106
200
  "search.scope.title": "Title",
107
201
  "search.scope.full": "Full text",
202
+ "search.scope.cross": "Session",
108
203
  "search.scope.tipTitle": "Titles only. Click for full-text search",
109
- "search.scope.tipFull": "Full text. Click for titles only",
204
+ "search.scope.tipFull": "Full text. Click for cross-session search",
205
+ "search.scope.tipCross": "Cross-session search (the host's full-text index over every session's message text). Click to go back to titles only",
206
+ "search.cross.loading": "Searching…",
207
+ "search.cross.empty": "No matches in other sessions",
208
+ "search.cross.error": "Search failed: ",
209
+ "search.cross.unavailable": "The host does not expose cross-session search",
210
+ "search.cross.disabled": "This deployment keeps the host's full-text session index off (session-query openAt: never), so cross-session search is unavailable",
211
+ "search.cross.more": "The host returns at most 20 results; more may exist",
212
+ "search.cross.note": "Cross-session search reads the host's full-text index over message text; the fuzzy switch does not apply",
213
+ "search.cross.hidden": "Also skipped {n} hit(s) from archived, subagent or unlisted sessions — they cannot be opened",
214
+ "search.cross.goneHit": "That hit's session cannot be opened right now (archived or not in the session list); skipped",
215
+ "search.cross.switched": "Switched to that session and started the same keyword search here",
216
+ "search.cross.noSpot": "The session is open, but that spot is not loaded yet — the jump was skipped; keep searching in the box",
110
217
  "search.fuzzy": "Fuzzy",
111
218
  "search.fuzzy.tipOn": "Fuzzy matching is on: a few other characters may sit between the keywords, so more matches hit",
112
219
  "search.fuzzy.tipOff": "Fuzzy matching is off: only contiguous text matches. Click to turn it on",
@@ -116,9 +223,12 @@ window.__ModuleLoader__.load({
116
223
  "hint.bottom": "You have reached the end",
117
224
  "outline.bottom": "Back to the newest entry",
118
225
  "turn.jumpReply": "Jump to the start of this turn's model reply",
226
+ "row.end": "Jump to the end of this section",
119
227
  "turn.jumpTurn": "Jump to the start of this turn",
120
228
  "turn.unloaded": "Not loaded",
121
229
  "turn.loadTip": "Click to load this turn and jump to it",
230
+ "turn.noMoreHistory": "The host reports no earlier history left for this session (its page window is already at the start), so this turn cannot be paged in",
231
+ "turn.loadFailed": "That turn could not be loaded (its history may no longer be in the session log)",
122
232
  "turn.failed": "Request failed",
123
233
  "turn.jumpFailed": "Jump to this turn's error",
124
234
  // NB: "time.beforeYesterday" stays Chinese-only on purpose — English has no
@@ -147,6 +257,8 @@ window.__ModuleLoader__.load({
147
257
  "settings.off": "Off",
148
258
  "settings.fuzzy": "Fuzzy search",
149
259
  "settings.hover": "Hover preview card",
260
+ "settings.remember": "Remember the reading position",
261
+ "settings.remember.tip": "When on, reopening a session returns to the turn you were last reading; the position is stored in this browser only",
150
262
  "settings.debug": "Print the mount diagnostic to the console",
151
263
  "settings.debug.tip": "The console then prints one mount line (host capabilities and jump-loader state)",
152
264
  "settings.overridden": "Customized",
@@ -177,6 +289,16 @@ window.__ModuleLoader__.load({
177
289
  if (s === undefined && activeLang !== "zh") s = DICTS.zh[key];
178
290
  return s === undefined ? key : s;
179
291
  }
292
+ // Same lookup, with `{name}` placeholders filled in. Kept separate from T so the
293
+ // host-locale path (which returns a plain string) still works: a translation that
294
+ // drops a placeholder simply loses that number rather than printing the braces.
295
+ function Tp(key, params) {
296
+ var s = T(key);
297
+ if (typeof s !== "string" || params === undefined || params === null) return s;
298
+ return s.replace(/\{(\w+)\}/g, function (match, name) {
299
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match;
300
+ });
301
+ }
180
302
  // The host reports the language it resolved for us; `auto` follows it, an explicit
181
303
  // preference wins, and an unknown host language falls back to Chinese.
182
304
  function setLanguage(pref, hostLang) {
@@ -209,14 +331,15 @@ window.__ModuleLoader__.load({
209
331
  // purpose — they describe THIS screen, not the account.
210
332
  var NAMESPACE = "dsh-quick-toc";
211
333
  // Fields backed by the Host settings document; everything else is localStorage.
212
- var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, zoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1 };
334
+ var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, zoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1 };
213
335
  // The schema defaults (mirrored here for the card's per-field reset and for the
214
336
  // one-time import of a 0.5.x install's stored values).
215
337
  var DEFAULTS = {
216
338
  dock: "left", y: 0, w: 0, h: 0,
217
339
  levels: [1, 2, 3, 4, 5, 6],
218
340
  zoom: 1, handle: 0.5,
219
- fuzzy: false, hover: true, lang: "auto", debug: false
341
+ fuzzy: false, hover: true, lang: "auto", debug: false,
342
+ remember: true
220
343
  };
221
344
  var PREF_KEYS = {
222
345
  dock: "dsh-quick-toc.dock.v2",
@@ -229,6 +352,7 @@ window.__ModuleLoader__.load({
229
352
  fuzzy: "dsh-quick-toc.fuzzy.v1",
230
353
  hover: "dsh-quick-toc.hover.v1",
231
354
  lang: "dsh-quick-toc.lang.v1",
355
+ remember: "dsh-quick-toc.remember.v1",
232
356
  debug: "dsh-quick-toc.debug"
233
357
  };
234
358
  // Writing a field's schema default through the card IS its reset — a value that
@@ -247,6 +371,54 @@ window.__ModuleLoader__.load({
247
371
  };
248
372
  var OLD_MAX_LEVEL_KEY = "dsh-quick-toc.maxLevel.v1";
249
373
  var LEVELS_ALL = [1, 2, 3, 4, 5, 6];
374
+ // ---- per-session reading position (browser-local, like panelW/H) ----------
375
+ // One small map in localStorage: sessionId -> { turn, seq, at }. Turn numbers are
376
+ // the stable coordinate (a node key can disappear with a re-framed log; the turn is
377
+ // what the host's turn index and the jump loader both speak), and `seq` is the
378
+ // turn's own start seq — the jump loader needs it to page a turn back in, so a
379
+ // remembered position outside the loaded window can actually be restored.
380
+ var READ_POS_KEY = "dsh-quick-toc.readPos.v1";
381
+ var READ_POS_MAX = 200;
382
+ // How long a remembered position stays worth resuming. "Where was I" is a
383
+ // convenience within a working session; coming back hours later to a stale anchor
384
+ // is how the reader ends up somewhere they did not ask for (they expect the newest
385
+ // messages). Past this window the position is dropped and the session opens at the
386
+ // bottom, where the live content is.
387
+ var READ_POS_TTL_MS = 30 * 60 * 1000;
388
+ var readPosMap = function () {
389
+ try {
390
+ var raw = localStorage.getItem(READ_POS_KEY);
391
+ var parsed = raw ? JSON.parse(raw) : null;
392
+ return parsed && typeof parsed === "object" ? parsed : {};
393
+ } catch (e) { return {}; }
394
+ };
395
+ var readPos = function (sessionId) {
396
+ if (!sessionId) return null;
397
+ var entry = readPosMap()[sessionId];
398
+ return entry && typeof entry.turn === "number" ? entry : null;
399
+ };
400
+ var writeReadPos = function (sessionId, turn, seq) {
401
+ if (!sessionId || typeof turn !== "number") return;
402
+ try {
403
+ var map = readPosMap();
404
+ map[sessionId] = typeof seq === "number" ? { turn: turn, seq: seq, at: Date.now() } : { turn: turn, at: Date.now() };
405
+ var ids = Object.keys(map);
406
+ if (ids.length > READ_POS_MAX) {
407
+ ids.sort(function (a, b) { return (map[a].at || 0) - (map[b].at || 0); });
408
+ for (var i = 0; i < ids.length - READ_POS_MAX; i++) delete map[ids[i]];
409
+ }
410
+ localStorage.setItem(READ_POS_KEY, JSON.stringify(map));
411
+ } catch (e) { /* storage full or blocked: a remembered position is a convenience */ }
412
+ };
413
+ var clearReadPos = function (sessionId) {
414
+ if (!sessionId) return;
415
+ try {
416
+ var map = readPosMap();
417
+ if (!Object.prototype.hasOwnProperty.call(map, sessionId)) return;
418
+ delete map[sessionId];
419
+ localStorage.setItem(READ_POS_KEY, JSON.stringify(map));
420
+ } catch (e) {}
421
+ };
250
422
  var prefsStore = (function () {
251
423
  var hasOwn = function (obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); };
252
424
  var read = function (key, legacy) {
@@ -310,6 +482,8 @@ window.__ModuleLoader__.load({
310
482
  fuzzy: read(PREF_KEYS.fuzzy) === "1",
311
483
  // absent means "on": the hover card predates this switch
312
484
  hover: read(PREF_KEYS.hover) !== "0",
485
+ // absent means "on" too (the reader asked for this to default on)
486
+ remember: read(PREF_KEYS.remember) !== "0",
313
487
  lang: langRaw === "zh" || langRaw === "en" ? langRaw : "auto",
314
488
  debug: read(PREF_KEYS.debug) === "1"
315
489
  };
@@ -325,6 +499,7 @@ window.__ModuleLoader__.load({
325
499
  handle: function (v) { return String(v); },
326
500
  fuzzy: function (v) { return v ? "1" : "0"; },
327
501
  hover: function (v) { return v ? "1" : "0"; },
502
+ remember: function (v) { return v ? "1" : "0"; },
328
503
  lang: function (v) { return v; },
329
504
  debug: function (v) { return v ? "1" : "0"; }
330
505
  };
@@ -355,6 +530,7 @@ window.__ModuleLoader__.load({
355
530
  handle: isFinite(handleNum) ? Math.min(1, Math.max(0, Math.round(handleNum * 100) / 100)) : 0.5,
356
531
  fuzzy: v.fuzzy === true,
357
532
  hover: v.hover !== false,
533
+ remember: v.remember !== false,
358
534
  debug: v.debug === true
359
535
  };
360
536
  };
@@ -365,7 +541,7 @@ window.__ModuleLoader__.load({
365
541
  return {
366
542
  dock: over.dock, y: local.y, w: local.w, h: local.h,
367
543
  levels: over.levels, zoom: over.zoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
368
- lang: over.lang, debug: over.debug
544
+ lang: over.lang, remember: over.remember, debug: over.debug
369
545
  };
370
546
  };
371
547
  var notify = function () {
@@ -1185,7 +1361,7 @@ window.__ModuleLoader__.load({
1185
1361
  position: "fixed",
1186
1362
  top: 80,
1187
1363
  right: 16,
1188
- zIndex: Z_BASE + 1,
1364
+ zIndex: Z_ERROR,
1189
1365
  background: "rgba(120,30,30,0.95)",
1190
1366
  border: "1px solid rgba(255,120,120,0.4)",
1191
1367
  color: "#ffdada",
@@ -1210,6 +1386,13 @@ window.__ModuleLoader__.load({
1210
1386
  // different number of hooks makes React abort the whole panel with
1211
1387
  // "Rendered more hooks than during the previous render" (#310). The hooks all
1212
1388
  // live in the body below, which is only ever mounted with complete props.
1389
+ // Cross-session hand-off. A host search hit is `{sessionId, snippet}` with no
1390
+ // seq, so the panel cannot scroll to it: it switches the session and leaves this
1391
+ // request here, and the panel that comes up for that session re-runs the keyword
1392
+ // and jumps to the first match it can actually reach. Module scope on purpose —
1393
+ // the panel for the new session may be a fresh mount.
1394
+ var pendingLocate = null;
1395
+
1213
1396
  function OutlinePanel(props) {
1214
1397
  if (!props.useChat) {
1215
1398
  console.warn("[dsh-quick-toc] useChat prop missing (requires DSH >= 0.1.5-rc.1)");
@@ -1251,6 +1434,10 @@ window.__ModuleLoader__.load({
1251
1434
  };
1252
1435
  // Which stage of the bridge is actually ready — reported once at mount so a
1253
1436
  // missing jump loader can be diagnosed from the console instead of guessed.
1437
+ // Past "the loader exists" the PAGE STATE is what decides whether a jump can do
1438
+ // anything: a window the host considers complete (hasMore false) or one whose
1439
+ // base already covers the target makes loadThrough resolve without loading, and
1440
+ // from the outline that is indistinguishable from "loading stopped working".
1254
1441
  var jumpLoaderState = function () {
1255
1442
  var sessions = hostSessions();
1256
1443
  if (!sessions) return "no-sessions-service";
@@ -1263,7 +1450,9 @@ window.__ModuleLoader__.load({
1263
1450
  }
1264
1451
  var face = binding && binding.session;
1265
1452
  if (!face) return "no-binding-for-session";
1266
- return typeof face.loadThrough === "function" ? "ready" : "no-loadThrough";
1453
+ if (typeof face.loadThrough !== "function") return "no-loadThrough";
1454
+ return "ready(open=" + String(face.openState) + ",hasMore=" + String(face.hasMore)
1455
+ + ",baseSeq=" + String(face.baseSeq) + ")";
1267
1456
  };
1268
1457
 
1269
1458
  // Structural narrowing of that projection (its value crosses the wire):
@@ -1300,23 +1489,47 @@ window.__ModuleLoader__.load({
1300
1489
  var viewport = _s2[0];
1301
1490
  var setViewport = _s2[1];
1302
1491
  react.useEffect(function () {
1492
+ // Self-healing, because the layout AROUND the conversation can change without
1493
+ // a resize: the host's own controls move that column about (measured in a real
1494
+ // browser: 40px of margin-left on the centre column shifted the scroller's
1495
+ // rect, no window resize was involved, and the plugin followed on this beat).
1496
+ // A stale rect parks the panel — and its collapse handle — on a line that no
1497
+ // longer exists, which reads as "the panel cannot be closed any more". The
1498
+ // observer is re-attached whenever the host swaps the scroller node out.
1499
+ var observed = null;
1500
+ var obs = null;
1501
+ var publish = function (next) {
1502
+ // the observer fires often; publishing an identical box would re-render
1503
+ // the whole list for nothing
1504
+ setViewport(function (prev) {
1505
+ if (prev &&
1506
+ Math.abs(prev.left - next.left) < 1 && Math.abs(prev.right - next.right) < 1 &&
1507
+ Math.abs(prev.top - next.top) < 1 && Math.abs(prev.height - next.height) < 1) {
1508
+ return prev;
1509
+ }
1510
+ return next;
1511
+ });
1512
+ };
1303
1513
  var compute = function () {
1304
1514
  var sp = document.querySelector("[data-conversation-scroll]");
1305
- if (sp) {
1306
- var r = sp.getBoundingClientRect();
1307
- setViewport({ left: r.left, right: window.innerWidth - r.right, top: r.top, height: r.height });
1515
+ if (!sp) return;
1516
+ if (sp !== observed) { // a replaced scroller: the old observation is worthless
1517
+ observed = sp;
1518
+ if (obs) obs.disconnect();
1519
+ if (typeof ResizeObserver !== "undefined") {
1520
+ obs = new ResizeObserver(function () { compute(); });
1521
+ obs.observe(sp);
1522
+ }
1308
1523
  }
1524
+ var r = sp.getBoundingClientRect();
1525
+ publish({ left: r.left, right: window.innerWidth - r.right, top: r.top, height: r.height });
1309
1526
  };
1310
1527
  compute();
1311
1528
  window.addEventListener("resize", compute);
1312
- var sp = document.querySelector("[data-conversation-scroll]");
1313
- var obs = null;
1314
- if (typeof ResizeObserver !== "undefined" && sp) {
1315
- obs = new ResizeObserver(compute);
1316
- obs.observe(sp);
1317
- }
1529
+ var beat = setInterval(compute, 500);
1318
1530
  return function () {
1319
1531
  window.removeEventListener("resize", compute);
1532
+ clearInterval(beat);
1320
1533
  if (obs) obs.disconnect();
1321
1534
  };
1322
1535
  }, []);
@@ -1336,18 +1549,47 @@ window.__ModuleLoader__.load({
1336
1549
  var setPanelY = _s4[1];
1337
1550
  var handleRef = react.useRef(null); // panel top drag bar
1338
1551
  var edgeRef = react.useRef(null); // edge collapse handle
1552
+ var sheetHandleRef = react.useRef(null); // curtain handle along the top edge
1339
1553
 
1340
1554
  // handle reveal: after the panel fully slides away, the handle fades+pops in
1341
1555
  var _s5 = react.useState(false);
1342
1556
  var handleShown = _s5[0];
1343
1557
  var setHandleShown = _s5[1];
1558
+ // ...and it is NOT unmounted the instant it is clicked: the collapse-side handle
1559
+ // used to disappear on the click's own frame, which read as a hard cut. It stays
1560
+ // mounted for the length of its fade-out and only then leaves the tree.
1561
+ var _s5b = react.useState(false);
1562
+ var edgeGone = _s5b[0];
1563
+ var setEdgeGone = _s5b[1];
1564
+ // ...and the panel itself waits for it: clicking the edge handle used to start the
1565
+ // panel's slide on the same frame, so the two crossed over and the panel looked
1566
+ // like it came out before the handle had gone in. While `edgeLeaving` is set the
1567
+ // panel's transition is delayed, so the handle slips home first and only then does
1568
+ // the panel slide out. (Its inline geometry is the open one from the first frame —
1569
+ // only the animation is held back — so nothing else has to move.)
1570
+ var _s5c = react.useState(false);
1571
+ var edgeLeaving = _s5c[0];
1572
+ var setEdgeLeaving = _s5c[1];
1573
+ var openFromEdge = function () {
1574
+ if (open) return;
1575
+ setEdgeLeaving(true);
1576
+ setOpen(true);
1577
+ };
1578
+ react.useEffect(function () {
1579
+ if (!edgeLeaving) return;
1580
+ var t = setTimeout(function () { setEdgeLeaving(false); }, EDGE_OUT_MS);
1581
+ return function () { clearTimeout(t); };
1582
+ }, [edgeLeaving]);
1344
1583
  react.useEffect(function () {
1345
1584
  if (!open) {
1346
1585
  setHandleShown(false);
1586
+ setEdgeGone(false);
1347
1587
  var t = setTimeout(function () { setHandleShown(true); }, 300); // right after the panel slides away
1348
1588
  return function () { clearTimeout(t); };
1349
1589
  }
1350
1590
  setHandleShown(false);
1591
+ var t2 = setTimeout(function () { setEdgeGone(true); }, EDGE_OUT_MS + 30); // the fade-out, then unmount
1592
+ return function () { clearTimeout(t2); };
1351
1593
  }, [open]);
1352
1594
  // panel hover: clear when mouse inside, transparent when outside
1353
1595
  var _s6 = react.useState(false);
@@ -1578,6 +1820,202 @@ window.__ModuleLoader__.load({
1578
1820
  };
1579
1821
  }, []);
1580
1822
 
1823
+ // ---- questions-only view ------------------------------------------------
1824
+ // A second way to read a long session: keep every turn's header (time + the
1825
+ // prompt) and drop the heading rows. The switch is a cross-fade, not a swap:
1826
+ // the list fades out, the mode changes while it is invisible, then it fades
1827
+ // back in — the two lists never blend into each other mid-swap. Session-local
1828
+ // on purpose (a reading mode, like the open search box, not a stored
1829
+ // preference).
1830
+ var _sQ = react.useState(false);
1831
+ var questionsOnly = _sQ[0];
1832
+ var setQuestionsOnly = _sQ[1];
1833
+ var _sQF = react.useState("idle"); // "idle" | "out" | "in"
1834
+ var listFade = _sQF[0];
1835
+ var setListFade = _sQF[1];
1836
+ var listFadeTimers = react.useRef({ out: null, in: null });
1837
+ var toggleQuestions = function () {
1838
+ if (listFadeTimers.current.out) clearTimeout(listFadeTimers.current.out);
1839
+ if (listFadeTimers.current.in) clearTimeout(listFadeTimers.current.in);
1840
+ // reduce motion is a real preference: swap without the two-phase fade
1841
+ var calm = false;
1842
+ try {
1843
+ calm = typeof window !== "undefined" && typeof window.matchMedia === "function"
1844
+ && window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
1845
+ } catch (e) { calm = false; }
1846
+ if (calm) {
1847
+ setQuestionsOnly(function (cur) { return !cur; });
1848
+ setListFade("idle");
1849
+ return;
1850
+ }
1851
+ setListFade("out");
1852
+ listFadeTimers.current.out = setTimeout(function () {
1853
+ listFadeTimers.current.out = null;
1854
+ setQuestionsOnly(function (cur) { return !cur; });
1855
+ // entering questions-only makes the level filter meaningless: close its
1856
+ // popup rather than leave a disabled control's menu open over the list
1857
+ if (!questionsOnly && typeof closeLevels === "function") closeLevels();
1858
+ setListFade("in");
1859
+ listFadeTimers.current.in = setTimeout(function () {
1860
+ listFadeTimers.current.in = null;
1861
+ setListFade("idle");
1862
+ }, 260);
1863
+ }, 150);
1864
+ };
1865
+ react.useEffect(function () {
1866
+ return function () {
1867
+ if (listFadeTimers.current.out) clearTimeout(listFadeTimers.current.out);
1868
+ if (listFadeTimers.current.in) clearTimeout(listFadeTimers.current.in);
1869
+ };
1870
+ }, []);
1871
+
1872
+ // ---- keyboard navigation -------------------------------------------------
1873
+ // The list is a real widget: ↑/↓ walk its rows in visual order, Home/End jump
1874
+ // to the ends, Enter activates the row under the cursor (each row keeps its own
1875
+ // click semantics — jump / page-in / switch session), Esc collapses the panel.
1876
+ // The cursor is a keyboard artefact, NOT the reading position: the blue box
1877
+ // follows the conversation, this outline follows the keys, and they may sit on
1878
+ // different rows. -1 = no cursor (nothing is painted).
1879
+ var _sNav = react.useState(-1);
1880
+ var navIdx = _sNav[0];
1881
+ var setNavIdx = _sNav[1];
1882
+ // Polite live region: match stepping, result counts and cursor moves are
1883
+ // announced for screen readers (and read out by any a11y layer the host adds).
1884
+ var _sLive = react.useState("");
1885
+ var liveText = _sLive[0];
1886
+ var setLiveText = _sLive[1];
1887
+
1888
+ // ---- full-width sheet ("幕布") -------------------------------------------
1889
+ // The same panel content, dropped from the top of the conversation area at full
1890
+ // width. It REPLACES the docked panel while open (the list keeps its ref, so
1891
+ // scrolling, page-in, the keyboard cursor and the hover card all keep working
1892
+ // against one element, not two).
1893
+ var _sSheet = react.useState(false);
1894
+ var sheetOpen = _sSheet[0];
1895
+ var setSheetOpen = _sSheet[1];
1896
+ var sheetRef = react.useRef(null);
1897
+ var sheetTimerRef = react.useRef(null);
1898
+ // Handing the docked panel over to the curtain. They are ONE list — while the
1899
+ // curtain is down the panel element IS the curtain's content — so switching
1900
+ // surfaces used to make the panel teleport: gone from the dock on the way
1901
+ // down, popping straight back in when the curtain retracted. The reader read
1902
+ // that as a flash. Now the panel always travels the way it always does:
1903
+ // while it is "aside" it renders at its collapsed spot and the existing
1904
+ // left/clip transition does the moving — it slides home just before the
1905
+ // curtain drops ("tuck"), and slides back in just after the curtain is gone
1906
+ // ("return") instead of popping into place.
1907
+ // "tuck" panel sliding home BEFORE the curtain opens (state must persist)
1908
+ // "return" panel sliding back in AFTER the curtain closed (one painted frame)
1909
+ var _sAside = react.useState(null);
1910
+ var panelAside = _sAside[0];
1911
+ var setPanelAside = _sAside[1];
1912
+ // The curtain hangs from just BELOW the centre column's view tabs (对话 / 轨迹 /
1913
+ // 上下文), not from the top of the scroll container: when those tabs live inside
1914
+ // that container, viewport.top sits above them and the curtain would cover them.
1915
+ // Both edges of that strip are kept: the curtain hangs from its BOTTOM line, and
1916
+ // the curtain handle sits INSIDE the strip with its own bottom on that same line
1917
+ // (that band is host chrome, so no conversation text can ever run under it —
1918
+ // which is exactly what made the handle unreadable when it hung below the line).
1919
+ // Measured when the conversation is (re)measured, not on every render —
1920
+ // getBoundingClientRect is a layout read.
1921
+ var _sSheetTop = react.useState(null);
1922
+ var sheetStrip = _sSheetTop[0]; // { top, bottom } | null
1923
+ var setSheetTop = _sSheetTop[1];
1924
+ // The curtain handle is held in the tree for DROP_OUT_MS after the curtain starts
1925
+ // opening, so its press-flat is actually seen (the curtain opens and would
1926
+ // otherwise unmount it in the same commit).
1927
+ var _sDropGone = react.useState(false);
1928
+ var sheetHandleGone = _sDropGone[0];
1929
+ var setSheetHandleGone = _sDropGone[1];
1930
+ // the droplet's click may defer the curtain until its press has played (see the
1931
+ // handle's onClick): one timer, always cancelled before being re-armed
1932
+ var dropOpenRef = react.useRef(null);
1933
+ react.useEffect(function () { return function () { if (dropOpenRef.current) clearTimeout(dropOpenRef.current); }; }, []);
1934
+ react.useEffect(function () {
1935
+ if (!sheetOpen) { setSheetHandleGone(false); return; }
1936
+ var t = setTimeout(function () { setSheetHandleGone(true); }, DROP_OUT_MS);
1937
+ return function () { clearTimeout(t); };
1938
+ }, [sheetOpen]);
1939
+ react.useEffect(function () {
1940
+ if (!viewport) return;
1941
+ var tabs = typeof conversationTablist === "function" ? conversationTablist() : null;
1942
+ var next = null;
1943
+ if (tabs && typeof tabs.getBoundingClientRect === "function") {
1944
+ var r = tabs.getBoundingClientRect();
1945
+ if (r && r.height > 0 && r.bottom > 0) next = { top: r.top, bottom: r.bottom };
1946
+ }
1947
+ setSheetTop(next);
1948
+ }, [sheetOpen, viewport]);
1949
+ react.useEffect(function () {
1950
+ return function () {
1951
+ if (sheetTimerRef.current) clearTimeout(sheetTimerRef.current);
1952
+ };
1953
+ }, []);
1954
+ // The retract is driven by a CLASS on the DOM node rather than by state: a
1955
+ // state change re-renders the whole (possibly huge) list on the very frame the
1956
+ // animation starts, and that long task is what made the retract stutter. React
1957
+ // only rewrites `class` when the prop changes, so a class added here survives
1958
+ // re-renders until the curtain is actually gone.
1959
+ var closeSheet = function () {
1960
+ if (!sheetOpen || sheetTimerRef.current) return;
1961
+ var el = sheetRef.current;
1962
+ if (el && el.classList) {
1963
+ el.classList.remove("dqt-sheet-open");
1964
+ el.classList.add("dqt-sheet-closing");
1965
+ }
1966
+ // the timer sits just past the exit animation (0.3s): there is no
1967
+ // animationend listener, so this is what actually closes the curtain
1968
+ sheetTimerRef.current = setTimeout(function () {
1969
+ sheetTimerRef.current = null;
1970
+ setSheetOpen(false);
1971
+ // the panel was open before the curtain: slide it back in instead of
1972
+ // letting it pop into place (the reader saw that pop as a flash)
1973
+ if (open) setPanelAside("return");
1974
+ }, 340);
1975
+ };
1976
+ var openSheet = function () {
1977
+ if (sheetOpen || panelAside) return;
1978
+ if (sheetTimerRef.current) { clearTimeout(sheetTimerRef.current); sheetTimerRef.current = null; }
1979
+ // reopening mid-retract: the node still carries the exit class and React
1980
+ // would not put the enter class back on its own (that prop never changed)
1981
+ var restoreEnterClass = function () {
1982
+ var node = sheetRef.current;
1983
+ if (node && node.classList) {
1984
+ node.classList.remove("dqt-sheet-closing");
1985
+ node.classList.add("dqt-sheet-open");
1986
+ }
1987
+ };
1988
+ if (open) {
1989
+ // The docked panel goes home FIRST — its own collapse motion, start to
1990
+ // finish — and only then does the curtain drop. Two reasons, both seen in
1991
+ // the browser: (1) the enter animation used to be armed at CLICK time, so
1992
+ // by the time the curtain became visible most of its 0.46s drop was
1993
+ // already spent and it looked like it snapped into place; (2) the panel
1994
+ // was pulled into the sheet mid-slide, which read as it flashing away.
1995
+ setPanelAside("tuck");
1996
+ sheetTimerRef.current = setTimeout(function () {
1997
+ sheetTimerRef.current = null;
1998
+ restoreEnterClass(); // the drop animation starts HERE, with the first paint
1999
+ setSheetOpen(true);
2000
+ setPanelAside(null);
2001
+ }, PANEL_TUCK_MS);
2002
+ } else {
2003
+ restoreEnterClass();
2004
+ setSheetOpen(true);
2005
+ }
2006
+ };
2007
+ // "return" only needs to exist for one painted frame: the panel renders at
2008
+ // its collapsed spot, and releasing it lets the left/clip transition carry
2009
+ // it back open. The tuck must NOT be released here — it is held until the
2010
+ // curtain actually opens.
2011
+ react.useEffect(function () {
2012
+ if (panelAside !== "return") return;
2013
+ var raf = requestAnimationFrame(function () {
2014
+ raf = requestAnimationFrame(function () { setPanelAside(null); });
2015
+ });
2016
+ return function () { cancelAnimationFrame(raf); };
2017
+ }, [panelAside]);
2018
+
1581
2019
  // ---- search: header button opens a keyword box; Enter cycles through
1582
2020
  // matching headings and jumps to each ----
1583
2021
  var _s13 = react.useState(false);
@@ -1608,9 +2046,18 @@ window.__ModuleLoader__.load({
1608
2046
  var prevScope = _s19[0];
1609
2047
  var setPrevScope = _s19[1];
1610
2048
  var scopeTimerRef = react.useRef(null);
2049
+ // search scope: "title" (default) / "full" (user messages + AI replies in this
2050
+ // session) / "cross" (the HOST's full-text index across every visible session).
2051
+ // ONE pill cycles the three, so a third scope costs no extra button.
2052
+ var scopeLabel = function (scope) {
2053
+ if (scope === "full") return T("search.scope.full");
2054
+ if (scope === "cross") return T("search.scope.cross");
2055
+ return T("search.scope.title");
2056
+ };
1611
2057
  var toggleScope = function () {
2058
+ var next = searchScope === "title" ? "full" : (searchScope === "full" ? "cross" : "title");
1612
2059
  setPrevScope(searchScope);
1613
- setSearchScope(searchScope === "title" ? "full" : "title");
2060
+ setSearchScope(next);
1614
2061
  setMatchIdx(0);
1615
2062
  if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
1616
2063
  scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
@@ -1626,6 +2073,59 @@ window.__ModuleLoader__.load({
1626
2073
  setMatchIdx(0);
1627
2074
  };
1628
2075
 
2076
+ // ---- cross-session search (the host's own full-text index) --------------
2077
+ // "cross" scope asks the HOST to search message text across every visible
2078
+ // session. Two host facts shape the code below: one search returns at most
2079
+ // `searchResultLimit` (20) items plus a `hasMore` flag, and an item carries
2080
+ // only `{sessionId, snippet}` — NO seq. So a hit cannot be jumped to directly:
2081
+ // clicking one switches to that session and re-runs the same keyword here (the
2082
+ // pendingLocate hand-off below).
2083
+ var _sCross = react.useState({ phase: "idle", rows: [], more: false, error: "" });
2084
+ var cross = _sCross[0];
2085
+ var setCross = _sCross[1];
2086
+ var crossAbortRef = react.useRef(null);
2087
+ var crossTimerRef = react.useRef(null);
2088
+ var crossTitle = function (id) {
2089
+ var sessions = hostSessions();
2090
+ try {
2091
+ var snap = sessions && sessions.list && typeof sessions.list.getSnapshot === "function"
2092
+ ? sessions.list.getSnapshot() : null;
2093
+ var rec = snap && snap.byId ? snap.byId[id] : null;
2094
+ if (rec && typeof rec.displayTitle === "string" && rec.displayTitle !== "") return rec.displayTitle;
2095
+ } catch (e) { /* title is decoration: the id is the fallback */ }
2096
+ return id;
2097
+ };
2098
+ // Which hits may be OFFERED. The host index covers every persisted session on
2099
+ // disk — including archived ones and sessions of workspaces this profile no
2100
+ // longer lists — so an unfiltered list offers rows that cannot be opened. The
2101
+ // rule below is the sidebar's own content-search rule (ui-workspace
2102
+ // `sessionVisible` + the registry archive set): listed, not a subagent child,
2103
+ // not archived, and never a blank session unless it is the current one.
2104
+ var crossHitAllowed = function (id) {
2105
+ if (typeof id !== "string" || id === "") return false;
2106
+ var sessions = hostSessions();
2107
+ var snap = null;
2108
+ try {
2109
+ snap = sessions && sessions.list && typeof sessions.list.getSnapshot === "function"
2110
+ ? sessions.list.getSnapshot() : null;
2111
+ } catch (e) { snap = null; }
2112
+ var rec = snap && snap.byId ? snap.byId[id] : null;
2113
+ if (rec === undefined || rec === null) return false; // not listed: cannot be opened
2114
+ if (rec.origin === "subagent") return false;
2115
+ if (snap.current === id) return true; // the open session always works
2116
+ if (rec.blank === true) return false; // nothing in it to have matched
2117
+ var archived = null;
2118
+ try {
2119
+ var ws = tocHost && typeof tocHost.workspaces === "function" ? tocHost.workspaces() : null;
2120
+ var wsnap = ws && ws.list && typeof ws.list.getSnapshot === "function" ? ws.list.getSnapshot() : null;
2121
+ archived = wsnap && Array.isArray(wsnap.archivedSessionIds) ? wsnap.archivedSessionIds : null;
2122
+ } catch (e) { archived = null; }
2123
+ // No workspace registry (older host): fall back to the session list's word,
2124
+ // which is still far better than offering every indexed session on disk.
2125
+ if (archived && archived.indexOf(id) >= 0) return false;
2126
+ return true;
2127
+ };
2128
+
1629
2129
  // ---- preference plumbing -------------------------------------------------
1630
2130
  // The store is the shared source (the native Settings section writes into it);
1631
2131
  // the states above are the panel's LIVE copy, so a drag or a toggle never waits
@@ -1723,7 +2223,11 @@ window.__ModuleLoader__.load({
1723
2223
  return undefined;
1724
2224
  }, [searchAnim]);
1725
2225
  react.useEffect(function () {
1726
- if (searchOpen && searchRef.current) searchRef.current.focus();
2226
+ if (!searchOpen || !searchRef.current) return;
2227
+ // preventScroll: across the curtain the input arrives inside a column that is
2228
+ // still animating open, and focusing it must not scroll anything
2229
+ try { searchRef.current.focus({ preventScroll: true }); }
2230
+ catch (e) { searchRef.current.focus(); }
1727
2231
  }, [searchOpen]);
1728
2232
  var onQueryChange = function (v) {
1729
2233
  setQuery(v);
@@ -2102,6 +2606,15 @@ window.__ModuleLoader__.load({
2102
2606
  didInitScroll.current = true;
2103
2607
  listRef.current.scrollTop = listRef.current.scrollHeight;
2104
2608
  }, [groups.length]);
2609
+ // A live handle on the newest group list, for callbacks that outlive their
2610
+ // render (the "did that turn actually page in?" verdict below).
2611
+ var groupsLiveRef = react.useRef(groups);
2612
+ groupsLiveRef.current = groups;
2613
+ // One history-rebuild retry per session after a jump that never landed.
2614
+ // Hooks must sit ABOVE every early return (this panel returns null for an
2615
+ // empty conversation) or React aborts with #310.
2616
+ var jumpRetryRef = react.useRef(false);
2617
+ react.useEffect(function () { jumpRetryRef.current = false; }, [sessionId]);
2105
2618
  // The "back to the newest row" button follows the list's real geometry, and a
2106
2619
  // bottom can be reached WITHOUT a scroll event: older turns page in, a search
2107
2620
  // replaces the rows, the panel is resized, the panel is expanded/collapsed.
@@ -2208,6 +2721,9 @@ window.__ModuleLoader__.load({
2208
2721
  var resultRows = react.useMemo(function () {
2209
2722
  var fq = foldQuery(query);
2210
2723
  if (!fq) return [];
2724
+ // cross-session scope lists HOST hits instead of local ones: the local list
2725
+ // (and the in-chat highlight it drives) stays out of the way.
2726
+ if (searchScope === "cross") return [];
2211
2727
  var needle = { folded: fq }; // one folded query, reused for every text
2212
2728
  var rows = [];
2213
2729
  var index = {};
@@ -2334,6 +2850,10 @@ window.__ModuleLoader__.load({
2334
2850
  activeSigRef.current = sig;
2335
2851
  setActiveGroup(actives);
2336
2852
  if (actives.length === 0) return;
2853
+ // remember where the reader is (debounced): the topmost visible turn, with
2854
+ // its start seq so a later restore can page it back in
2855
+ var giSave = actives[0];
2856
+ if (groups[giSave]) savePosition(groups[giSave]);
2337
2857
  // pause right after the reader touched the outline themselves —
2338
2858
  // otherwise paging older turns gets yanked back to the newest group
2339
2859
  if (Date.now() - outlineTouchRef.current <= 1500) return;
@@ -2377,6 +2897,9 @@ window.__ModuleLoader__.load({
2377
2897
  var matches = react.useMemo(function () {
2378
2898
  var fq = foldQuery(query);
2379
2899
  if (!fq) return [];
2900
+ // cross-session scope: the host owns the searching, so there are no local
2901
+ // matches — no n/N stepping and no keyword highlight in this conversation.
2902
+ if (searchScope === "cross") return [];
2380
2903
  var needle = { folded: fq };
2381
2904
  var out = [];
2382
2905
  for (var gi = 0; gi < groups.length; gi++) {
@@ -2429,6 +2952,142 @@ window.__ModuleLoader__.load({
2429
2952
  }
2430
2953
  }
2431
2954
 
2955
+ // ---- cross-session query: debounce, abort the superseded one ------------
2956
+ // 320ms of quiet keeps a fast typist from spending the host's search budget
2957
+ // on prefixes; the previous request is aborted, and an aborted reply is
2958
+ // dropped (a late answer for an older query must not overwrite the new one).
2959
+ react.useEffect(function () {
2960
+ var q = query.trim();
2961
+ var clearPending = function () {
2962
+ if (crossTimerRef.current) { clearTimeout(crossTimerRef.current); crossTimerRef.current = null; }
2963
+ };
2964
+ if (searchScope !== "cross" || q === "") {
2965
+ if (crossAbortRef.current) { crossAbortRef.current.abort(); crossAbortRef.current = null; }
2966
+ clearPending();
2967
+ setCross(function (cur) {
2968
+ return (cur.phase === "idle" && cur.rows.length === 0) ? cur : { phase: "idle", rows: [], more: false, error: "" };
2969
+ });
2970
+ return;
2971
+ }
2972
+ var sessions = hostSessions();
2973
+ if (!sessions || typeof sessions.search !== "function") {
2974
+ setCross({ phase: "error", rows: [], more: false, error: T("search.cross.unavailable") });
2975
+ return;
2976
+ }
2977
+ clearPending();
2978
+ crossTimerRef.current = setTimeout(function () {
2979
+ crossTimerRef.current = null;
2980
+ if (crossAbortRef.current) crossAbortRef.current.abort();
2981
+ var ctl = (typeof AbortController !== "undefined") ? new AbortController() : null;
2982
+ crossAbortRef.current = ctl;
2983
+ setCross({ phase: "loading", rows: [], more: false, error: "" });
2984
+ var started;
2985
+ try {
2986
+ started = sessions.search(q, ctl ? ctl.signal : undefined);
2987
+ } catch (e) {
2988
+ setCross({ phase: "error", rows: [], more: false, error: String(e && e.message ? e.message : e) });
2989
+ return;
2990
+ }
2991
+ Promise.resolve(started).then(function (result) {
2992
+ if (ctl && ctl.signal.aborted) return;
2993
+ if (!result || result.ok !== true) {
2994
+ var msg = (result && result.error && result.error.message) ? String(result.error.message) : "unknown error";
2995
+ // The deployment default is an index that is never opened, and the host
2996
+ // says so in engineering terms. Say what it means for the reader instead,
2997
+ // and keep the host's own words available for the "any other error" case.
2998
+ if (/SESSION_QUERY_SEARCH_DISABLED|session search is disabled|openAt/i.test(msg)) {
2999
+ msg = T("search.cross.disabled");
3000
+ }
3001
+ setCross({ phase: "error", rows: [], more: false, error: msg });
3002
+ return;
3003
+ }
3004
+ var value = result.value || {};
3005
+ var items = Array.isArray(value.items) ? value.items : [];
3006
+ var rows = [];
3007
+ var hidden = 0;
3008
+ for (var i = 0; i < items.length; i++) {
3009
+ var it = items[i];
3010
+ if (!it || typeof it.sessionId !== "string") continue;
3011
+ // never offer a hit the panel could not open (archived, unlisted,
3012
+ // subagent child): the index covers every persisted session on disk
3013
+ if (!crossHitAllowed(it.sessionId)) { hidden += 1; continue; }
3014
+ rows.push({ sessionId: it.sessionId, snippet: typeof it.snippet === "string" ? it.snippet : "" });
3015
+ }
3016
+ setCross({ phase: "done", rows: rows, more: value.hasMore === true, error: "", hidden: hidden });
3017
+ }, function (e) {
3018
+ if (ctl && ctl.signal.aborted) return;
3019
+ setCross({ phase: "error", rows: [], more: false, error: String(e && e.message ? e.message : e) });
3020
+ });
3021
+ }, 320);
3022
+ }, [searchScope, query, langKey]);
3023
+
3024
+ // leaving the panel unmounts it: abort an in-flight host search
3025
+ react.useEffect(function () {
3026
+ return function () {
3027
+ if (crossAbortRef.current) crossAbortRef.current.abort();
3028
+ if (crossTimerRef.current) clearTimeout(crossTimerRef.current);
3029
+ };
3030
+ }, []);
3031
+
3032
+ // clicking a cross-session hit: switch the session, then leave the keyword for
3033
+ // the panel that comes up (see pendingLocate) — the host hit has no seq, so the
3034
+ // jump happens on the other side, after the same search runs there.
3035
+ var openCrossHit = function (hitSessionId) {
3036
+ var sessions = hostSessions();
3037
+ if (!sessions || typeof sessions.open !== "function") {
3038
+ showBanner(T("search.cross.unavailable"));
3039
+ return;
3040
+ }
3041
+ // the list/archive set can change between the search and the click
3042
+ if (!crossHitAllowed(hitSessionId)) {
3043
+ showBanner(T("search.cross.goneHit"));
3044
+ return;
3045
+ }
3046
+ pendingLocate = { sessionId: hitSessionId, query: query.trim(), at: Date.now() };
3047
+ try {
3048
+ sessions.open(hitSessionId);
3049
+ } catch (e) {
3050
+ pendingLocate = null;
3051
+ showBanner(T("search.cross.error") + String(e && e.message ? e.message : e));
3052
+ return;
3053
+ }
3054
+ showBanner(T("search.cross.switched"));
3055
+ };
3056
+
3057
+ // the arriving side of that hand-off: prefill the keyword in full-text scope,
3058
+ // then jump to the first row that really contains it. Best effort by design —
3059
+ // the spot may sit in history this window has not paged in, so a grace period
3060
+ // ends in an honest "not loaded yet" banner rather than a silent nothing.
3061
+ var locateTimerRef = react.useRef(null);
3062
+ react.useEffect(function () {
3063
+ var req = pendingLocate;
3064
+ if (!req) return;
3065
+ if (req.sessionId !== sessionId || !req.query) return;
3066
+ if (Date.now() - req.at > 15000) { pendingLocate = null; return; }
3067
+ if (query !== req.query) { setQuery(req.query); setSearchScope("full"); return; }
3068
+ if (searchScope !== "full") { setSearchScope("full"); return; }
3069
+ if (resultRows.length === 0) {
3070
+ if (!locateTimerRef.current) {
3071
+ locateTimerRef.current = setTimeout(function () {
3072
+ locateTimerRef.current = null;
3073
+ if (pendingLocate === req) {
3074
+ pendingLocate = null;
3075
+ showBanner(T("search.cross.noSpot"));
3076
+ }
3077
+ }, 2600);
3078
+ }
3079
+ return;
3080
+ }
3081
+ if (locateTimerRef.current) { clearTimeout(locateTimerRef.current); locateTimerRef.current = null; }
3082
+ pendingLocate = null;
3083
+ var pick = 0;
3084
+ for (var i = 0; i < resultRows.length; i++) {
3085
+ var r = resultRows[i];
3086
+ if (((r.title || "") + " " + (r.snippet || "")).indexOf(req.query) >= 0) { pick = i; break; }
3087
+ }
3088
+ goToRow(pick);
3089
+ }, [sessionId, query, searchScope, resultRows]);
3090
+
2432
3091
  // The result list follows the outline's convention: OLDEST hit at the top,
2433
3092
  // NEWEST at the bottom, and a fresh search starts scrolled to the bottom —
2434
3093
  // scroll UP from there to walk back through earlier hits. The newest hit is
@@ -2442,11 +3101,14 @@ window.__ModuleLoader__.load({
2442
3101
  if (!q) { setHint(""); return; }
2443
3102
  var el = listRef.current;
2444
3103
  if (el) el.scrollTop = el.scrollHeight;
3104
+ // cross-session scope: the list is host hits, so neither the n/N cursor nor
3105
+ // the "scroll up for older messages" hint applies
3106
+ if (searchScope === "cross") { setHint(""); return; }
2445
3107
  if (matches.length > 0) setMatchIdx(matches.length - 1);
2446
3108
  // arm the "scroll up for older messages" hint: the result list only covers
2447
3109
  // what the window holds, and older turns are one upward scroll away
2448
3110
  setHint(canLoadOlder() ? "more" : "");
2449
- }, [query, matches.length]);
3111
+ }, [query, matches.length, searchScope]);
2450
3112
 
2451
3113
  // keep the outline filled: after opening the panel or dragging it taller,
2452
3114
  // the latest page may be shorter than the list viewport, leaving dead space
@@ -2515,6 +3177,130 @@ window.__ModuleLoader__.load({
2515
3177
  return function () { el.removeEventListener("wheel", onWheel); };
2516
3178
  }, [open, crowded, groups.length]);
2517
3179
 
3180
+ // ---- remember the reading position --------------------------------------
3181
+ // Saved: the turn the reader is actually looking at (the first group crossing
3182
+ // the conversation viewport, the same signal the rail follows), written 700ms
3183
+ // after the scrolling stops. Restored: once per session mount, quietly — a
3184
+ // position that can no longer be paged in is forgotten rather than announced.
3185
+ var restoreDoneRef = react.useRef(false);
3186
+ var restoreQuietRef = react.useRef(0);
3187
+ var posTimerRef = react.useRef(null);
3188
+ var rememberRef = react.useRef(true);
3189
+ rememberRef.current = !!prefs.remember;
3190
+ react.useEffect(function () { return function () { if (posTimerRef.current) clearTimeout(posTimerRef.current); }; }, []);
3191
+ // `g` is the outline group the reader is looking at (its turn AND its start seq,
3192
+ // so the position can be paged back in later, not just jumped to while loaded)
3193
+ var savePosition = function (g) {
3194
+ if (!rememberRef.current) return;
3195
+ if (!g || typeof g.turn !== "number") return;
3196
+ if (Date.now() - restoreQuietRef.current < 2500) return; // the restore's own scrolling
3197
+ if (posTimerRef.current) clearTimeout(posTimerRef.current);
3198
+ posTimerRef.current = setTimeout(function () {
3199
+ posTimerRef.current = null;
3200
+ writeReadPos(sessionId, g.turn, g.seq);
3201
+ }, 700);
3202
+ };
3203
+
3204
+ // ---- keyboard cursor: paint + keep in view ------------------------------
3205
+ // Hooks MUST sit above the early return below (a conditional hook aborts the
3206
+ // whole panel with React #310); the helpers they call are defined later, which
3207
+ // is fine because the callbacks run after the render.
3208
+ react.useEffect(function () {
3209
+ var el = listRef.current;
3210
+ var rows = el && typeof el.querySelectorAll === "function"
3211
+ ? Array.prototype.slice.call(el.querySelectorAll("[data-nav-row]")) : [];
3212
+ if (navIdx >= rows.length) { setNavIdx(rows.length > 0 ? rows.length - 1 : -1); return; }
3213
+ for (var i = 0; i < rows.length; i++) {
3214
+ if (rows[i] && rows[i].classList && typeof rows[i].classList.toggle === "function") {
3215
+ rows[i].classList.toggle("dqt-nav", i === navIdx);
3216
+ }
3217
+ }
3218
+ if (navIdx >= 0 && navIdx < rows.length && el && typeof el.getBoundingClientRect === "function"
3219
+ && typeof rows[navIdx].getBoundingClientRect === "function") {
3220
+ var box = el.getBoundingClientRect();
3221
+ var rr = rows[navIdx].getBoundingClientRect();
3222
+ if (box && rr && typeof box.top === "number" && typeof rr.top === "number") {
3223
+ var factor = zoom > 0 ? zoom : 1;
3224
+ if (rr.top < box.top + 2) el.scrollTop -= (box.top + 2 - rr.top) / factor;
3225
+ else if (rr.bottom > box.bottom - 2) el.scrollTop += (rr.bottom - box.bottom + 2) / factor;
3226
+ }
3227
+ }
3228
+ }, [navIdx, groups, resultRows, cross, query, visibleCount, open, questionsOnly]);
3229
+ // live region: what the reader just did (search hits / results / cursor)
3230
+ react.useEffect(function () {
3231
+ if (!query.trim()) { setLiveText(""); return; }
3232
+ if (searchScope === "cross") {
3233
+ if (cross.phase === "loading") setLiveText(T("a11y.loading"));
3234
+ else if (cross.phase === "error") setLiveText(cross.error || T("a11y.crossEmpty"));
3235
+ else setLiveText(cross.rows.length > 0 ? Tp("a11y.results", { n: cross.rows.length }) : T("a11y.crossEmpty"));
3236
+ return;
3237
+ }
3238
+ if (matches.length > 0) setLiveText(Tp("a11y.hits", { n: (matchIdx % matches.length) + 1, total: matches.length }));
3239
+ else setLiveText(T("search.empty"));
3240
+ }, [query, searchScope, cross.phase, cross.rows.length, cross.error, matches.length, matchIdx, langKey]);
3241
+
3242
+ // ---- restore the remembered position (once per session mount) -----------
3243
+ // Hooks must stay above the early return below. Runs after the first content
3244
+ // arrives; a stored turn that is already the newest (or within the newest two
3245
+ // groups) means there is nothing to restore, and a position older than
3246
+ // READ_POS_TTL_MS is dropped instead of resumed: coming back later, the reader
3247
+ // expects the newest messages, not an anchor from a previous sitting.
3248
+ react.useEffect(function () {
3249
+ if (restoreDoneRef.current) return;
3250
+ if (groups.length === 0) return;
3251
+ if (!chatViewActive) return;
3252
+ restoreDoneRef.current = true;
3253
+ if (!prefs.remember) return;
3254
+ var saved = readPos(sessionId);
3255
+ if (!saved) return;
3256
+ if (typeof saved.at === "number" && Date.now() - saved.at > READ_POS_TTL_MS) {
3257
+ clearReadPos(sessionId); // stale: open at the bottom, as if it were never saved
3258
+ return;
3259
+ }
3260
+ var newest = [];
3261
+ for (var i = Math.max(0, groups.length - 2); i < groups.length; i++) {
3262
+ if (typeof groups[i].turn === "number") newest.push(groups[i].turn);
3263
+ }
3264
+ if (newest.indexOf(saved.turn) >= 0) return;
3265
+ restoreQuietRef.current = Date.now();
3266
+ var gi = -1;
3267
+ for (var j = 0; j < groups.length; j++) {
3268
+ if (groups[j].turn === saved.turn) { gi = j; break; }
3269
+ }
3270
+ if (gi >= 0 && !groups[gi].ghost) {
3271
+ var g = groups[gi];
3272
+ var key = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key
3273
+ : (g.userKey || (g.headings.length > 0 ? g.headings[0].key : ""));
3274
+ if (key) {
3275
+ jump(key, 0);
3276
+ revealGroupInOutline(gi);
3277
+ return;
3278
+ }
3279
+ }
3280
+ // Not in the loaded window (or nothing jumpable in it): page it in quietly.
3281
+ // The seq comes from the host outline — the stored entry may predate the seq
3282
+ // being written, and loadThrough(undefined) pages nowhere, so the restore used
3283
+ // to sit out its 8s deadline and then forget a position it could have resumed.
3284
+ var seq = typeof saved.seq === "number" ? saved.seq : null;
3285
+ if (seq === null) {
3286
+ for (var k = 0; k < groups.length; k++) {
3287
+ if (groups[k] && groups[k].turn === saved.turn && typeof groups[k].seq === "number") { seq = groups[k].seq; break; }
3288
+ }
3289
+ }
3290
+ openTurn(saved.turn, seq === null ? undefined : seq, true);
3291
+ }, [groups, prefs.remember, chatViewActive, sessionId]);
3292
+
3293
+ // ---- opening the sheet: land on the turn being read ----------------------
3294
+ // Hooks stay above the early return; the helpers they call are assigned later
3295
+ // in the render body, which is fine because this runs after the render.
3296
+ react.useEffect(function () {
3297
+ if (!sheetOpen) return;
3298
+ var el = listRef.current;
3299
+ if (el && typeof el.focus === "function") el.focus();
3300
+ var gi = activeGroup && activeGroup.length > 0 ? activeGroup[0] : -1;
3301
+ if (gi >= 0) revealGroupInOutline(gi);
3302
+ }, [sheetOpen]);
3303
+
2518
3304
  // ---- nothing to show without headings ----
2519
3305
  if (groups.length === 0) return null;
2520
3306
 
@@ -2522,6 +3308,21 @@ window.__ModuleLoader__.load({
2522
3308
  // (The scale preference leaves all of this alone: it acts on the panel's inner
2523
3309
  // box, so the panel itself keeps the width and height the reader dragged.)
2524
3310
  var dockRight = dock === "right";
3311
+ // Keep the OPEN panel inside the window whatever the conversation area does.
3312
+ // The area is the host's to move: its transcript width grips rewrite the
3313
+ // column, a sidebar or another plugin's split view can shift or shrink it,
3314
+ // and the reader can also change the window itself. A panel pushed past the
3315
+ // edge takes its own header — and with it the only ✕ — out of reach, which
3316
+ // is the difference between "misplaced" and "cannot be closed any more".
3317
+ // (The collapsed panel is parked deliberately outside the area and is hidden
3318
+ // by its own clip, so only the open position is clamped.)
3319
+ var clampLeft = function (x) {
3320
+ return Math.max(8, Math.min(Math.max(8, window.innerWidth - panelW - 8), x));
3321
+ };
3322
+ // While the panel is being handed over to (or back from) the curtain it is
3323
+ // rendered at its COLLAPSED geometry, so the same left/clip transition that
3324
+ // serves open/collapse also serves the handoff: no teleport, no flash.
3325
+ var geoOpen = open && !panelAside;
2525
3326
  var panelLeft;
2526
3327
  if (dockRight) {
2527
3328
  // RIGHT dock: keep the panel's VISIBLE right edge pinned at the SAME
@@ -2531,15 +3332,20 @@ window.__ModuleLoader__.load({
2531
3332
  // so the visible right edge stays at openRight+panelW -> the
2532
3333
  // collapse shrinks toward that line and vanishes exactly there.
2533
3334
  var openRight = window.innerWidth - (viewport ? viewport.right + 48 : 60) - panelW;
2534
- panelLeft = open ? openRight : (openRight + panelW);
2535
- } else if (open) {
3335
+ panelLeft = geoOpen ? clampLeft(openRight) : (openRight + panelW);
3336
+ } else if (geoOpen) {
2536
3337
  // LEFT dock open (left dock is the confirmed-correct reference — untouched)
2537
- panelLeft = (viewport ? viewport.left + 8 : 8);
3338
+ panelLeft = clampLeft(viewport ? viewport.left + 8 : 8);
2538
3339
  } else {
2539
3340
  panelLeft = (viewport ? viewport.left - panelW - 8 : -(panelW + 48));
2540
3341
  }
2541
3342
  var vMaxH = viewport ? Math.max(200, viewport.height - 28) : "72vh";
2542
- var baseTopPx = (viewport ? viewport.top + 14 : window.innerHeight * 0.12) + panelY;
3343
+ // same reasoning vertically: the draggable offset is stored in localStorage
3344
+ // and can outlive the layout it was chosen for
3345
+ var baseTopPx = Math.max(8, Math.min(
3346
+ Math.max(8, window.innerHeight - 120),
3347
+ (viewport ? viewport.top + 14 : window.innerHeight * 0.12) + panelY
3348
+ ));
2543
3349
  // the collapsed handle's vertical position (a 0..1 ratio of the conversation
2544
3350
  // area — 0 pins the handle to the BOTTOM, 1 to the top; 0.5 = centred, which is
2545
3351
  // exactly the previous fixed position)
@@ -2597,10 +3403,12 @@ window.__ModuleLoader__.load({
2597
3403
  var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
2598
3404
  return Math.max(0, Math.min(t, floor));
2599
3405
  };
2600
- var glideTo = function (el) {
2601
- var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
2602
- if (!sp) return;
2603
- var t0 = scrollTargetOf(el, sp);
3406
+ // The glide machinery is target-agnostic on purpose: the target is a live
3407
+ // function of the scrollport, so the same stick-zone lift, drift re-aim,
3408
+ // stall watchdog and landing re-checks serve both "put this heading at the
3409
+ // top" (jump) and "put this section's end at the visible bottom" (end jump).
3410
+ var glideToTarget = function (sp, getTarget) {
3411
+ var t0 = getTarget(sp);
2604
3412
  var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
2605
3413
  if (floor - sp.scrollTop <= 25 && Math.abs(t0 - sp.scrollTop) > 60) {
2606
3414
  sp.scrollTop = Math.max(0, floor - 26); // break the stick-to-bottom hold first
@@ -2626,7 +3434,7 @@ window.__ModuleLoader__.load({
2626
3434
  var ticks = 0;
2627
3435
  var iv = setInterval(function () {
2628
3436
  if (mine.done || (ticks += 1) > 5) { clearInterval(iv); return; }
2629
- var next = scrollTargetOf(el, sp);
3437
+ var next = getTarget(sp);
2630
3438
  if (Math.abs(next - sp.scrollTop) > 2) sp.scrollTop = next;
2631
3439
  }, 120);
2632
3440
  };
@@ -2644,7 +3452,7 @@ window.__ModuleLoader__.load({
2644
3452
  if (mine.done) { if (glide === mine) glide = null; return; }
2645
3453
  var k = Math.min(1, (Date.now() - started) / dur);
2646
3454
  var eased = 1 - (1 - k) * (1 - k); // quick off the mark, long coast to a stop
2647
- var live = scrollTargetOf(el, sp); // the target's offset RIGHT NOW
3455
+ var live = getTarget(sp); // the target's offset RIGHT NOW
2648
3456
  sp.scrollTop = start + (live - start) * eased;
2649
3457
  if (k < 1) requestAnimationFrame(frame);
2650
3458
  else land();
@@ -2676,7 +3484,7 @@ window.__ModuleLoader__.load({
2676
3484
  var stalls = 0;
2677
3485
  var watch = setInterval(function () {
2678
3486
  if (mine.done || glide !== mine || drawn) { clearInterval(watch); return; }
2679
- var live = scrollTargetOf(el, sp);
3487
+ var live = getTarget(sp);
2680
3488
  var top = sp.scrollTop;
2681
3489
  if (Math.abs(live - aimed) > 40) { aim(live); lastTop = top; lastMove = Date.now(); return; }
2682
3490
  if (Math.abs(top - aimed) <= 2) { clearInterval(watch); land(); return; }
@@ -2690,6 +3498,12 @@ window.__ModuleLoader__.load({
2690
3498
  }, 120);
2691
3499
  };
2692
3500
 
3501
+ var glideTo = function (el) {
3502
+ var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
3503
+ if (!sp) return;
3504
+ glideToTarget(sp, function (s) { return scrollTargetOf(el, s); });
3505
+ };
3506
+
2693
3507
  var jump = function (key, idx) {
2694
3508
  var row = findRow(key);
2695
3509
  if (!row) return;
@@ -2701,6 +3515,84 @@ window.__ModuleLoader__.load({
2701
3515
  glideTo(el);
2702
3516
  };
2703
3517
 
3518
+ // ---- jump to the END of a section ----------------------------------------
3519
+ // Every outline row and every group header also carries a small "to the
3520
+ // section's end" control at its far right. The landing is the boundary where
3521
+ // that content stops: for a heading the first subsequent heading of the same
3522
+ // or a higher level, never past the end of its own turn; for a group header
3523
+ // the end of the turn. The boundary is set down just above the sticky
3524
+ // composer seat (its height rides in --dsh-composer-height) so the section's
3525
+ // tail is actually on screen instead of hidden underneath the input box.
3526
+ // Everything shares the target-agnostic glide, so a long end jump gets the
3527
+ // same stick-zone lift, drift re-aim and landing re-checks as a normal jump.
3528
+ var endLimitOf = function (el, sp) {
3529
+ var spTop = sp.getBoundingClientRect().top;
3530
+ var elTop = el.getBoundingClientRect().top;
3531
+ // Everything returned here is SCROLLER-relative (viewport offset + scrollTop):
3532
+ // endTargetTop works in scroller coordinates, and mixing the two frames made
3533
+ // the landing miss by exactly one scrollTop — measured in the browser, the
3534
+ // section it was aiming at ended up thousands of pixels BELOW the viewport.
3535
+ var base = sp.scrollTop - spTop;
3536
+ var limit = null; // scroller-relative y where this entry's content stops
3537
+ if (el.tagName && /^H[1-6]$/.test(String(el.tagName))) {
3538
+ var lvl = Number(String(el.tagName).slice(1));
3539
+ var hs = sp.querySelectorAll("h1, h2, h3, h4, h5, h6");
3540
+ for (var i = 0; i < hs.length; i++) {
3541
+ var h = hs[i];
3542
+ if (h === el) continue;
3543
+ if (h.getBoundingClientRect().top <= elTop + 1) continue;
3544
+ if (Number(String(h.tagName).slice(1)) > lvl) continue; // a child, not a boundary
3545
+ limit = h.getBoundingClientRect().top + base;
3546
+ break;
3547
+ }
3548
+ }
3549
+ var turnEl = el.closest ? el.closest("[data-chat-turn]") : null;
3550
+ if (turnEl) {
3551
+ var tb = turnEl.getBoundingClientRect().bottom + base;
3552
+ if (limit === null || tb < limit) limit = tb;
3553
+ }
3554
+ return limit;
3555
+ };
3556
+ var endTargetTop = function (limit, sp) {
3557
+ var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
3558
+ if (limit === null) return floor; // nothing bounds it: the very end of the content
3559
+ var seat = parseInt(getComputedStyle(sp).getPropertyValue("--dsh-composer-height"), 10) || 0;
3560
+ var t = limit - (sp.clientHeight - seat) + 28;
3561
+ return Math.max(0, Math.min(t, floor));
3562
+ };
3563
+ var endJumpRow = function (key, idx) {
3564
+ var row = findRow(key);
3565
+ if (!row) return;
3566
+ var el = row;
3567
+ if (idx !== undefined && idx !== null) {
3568
+ var hs = row.querySelectorAll("h1, h2, h3, h4, h5, h6");
3569
+ if (hs.length > 0) el = hs[Math.min(idx, hs.length - 1)] || row;
3570
+ }
3571
+ var sp = el.closest ? el.closest("[data-conversation-scroll]") : null;
3572
+ if (!sp) return;
3573
+ glideToTarget(sp, function (s) { return endTargetTop(endLimitOf(el, s), s); });
3574
+ };
3575
+ // A group header's control lands on the end of the WHOLE turn: the bottom of
3576
+ // the turn's LAST message (the failure row for a failed turn), so the reader
3577
+ // ends up at the bottom of the model's reply.
3578
+ // It deliberately does NOT go through the [data-chat-turn] element. Measured in
3579
+ // the browser: that attribute matches a small marker (0-80px tall in the
3580
+ // sessions probed) whose bottom sits thousands of pixels away from the reply it
3581
+ // names — the last group's marker ran 637->717 in the viewport while that
3582
+ // group's message ran 3539->3984, so anchoring to it dropped the reader nowhere
3583
+ // near the group they clicked.
3584
+ var endJumpTurn = function (lastKey) {
3585
+ var row = lastKey ? findRow(lastKey) : null;
3586
+ if (!row) return;
3587
+ var sp = row.closest ? row.closest("[data-conversation-scroll]") : null;
3588
+ if (!sp) return;
3589
+ glideToTarget(sp, function (s) {
3590
+ // scroller-relative (+ scrollTop): re-evaluated on every tick
3591
+ var base = row.getBoundingClientRect().bottom + s.scrollTop - s.getBoundingClientRect().top;
3592
+ return endTargetTop(base, s);
3593
+ });
3594
+ };
3595
+
2704
3596
  // ---- unloaded turns: page history to the turn, then land on it ----------
2705
3597
  // The host turn outline carries each turn's `turn/start` seq and the session
2706
3598
  // face's loadThrough(seq) is the documented jump loader ("page history
@@ -2748,24 +3640,123 @@ window.__ModuleLoader__.load({
2748
3640
  };
2749
3641
 
2750
3642
  // returns false when the host exposes no jump loader (older DSH builds)
2751
- var openTurn = function (turn, seq) {
3643
+ var turnLanded = function (turn) {
3644
+ var list = groupsLiveRef.current || [];
3645
+ for (var i = 0; i < list.length; i++) {
3646
+ if (list[i].turn === turn && !list[i].ghost) return true;
3647
+ }
3648
+ return false;
3649
+ };
3650
+ var openTurn = function (turn, seq, quiet) {
2752
3651
  var sessions = hostSessions();
2753
3652
  if (!sessions) {
2754
3653
  console.warn("[dsh-quick-toc] ctx.get(\"sessions\") returned nothing: this host has no session service, cannot open turn " + turn);
3654
+ if (!quiet) showBanner(T("turn.loadFailed"));
2755
3655
  return false;
2756
3656
  }
2757
3657
  var binding = typeof sessions.binding === "function" ? sessions.binding(sessionId) : null;
2758
3658
  var face = binding && binding.session;
2759
3659
  if (!face || typeof face.loadThrough !== "function") {
2760
3660
  console.warn("[dsh-quick-toc] session " + String(sessionId) + " exposes no loadThrough jump loader, cannot open turn " + turn);
3661
+ if (!quiet) showBanner(T("turn.loadFailed"));
2761
3662
  return false;
2762
3663
  }
3664
+ // The loader silently no-ops when the host considers the window complete
3665
+ // (`hasMore === false`) — that is DEFINITIVE evidence this turn can never be
3666
+ // paged in, so it gets an honest banner. Every other state (session still
3667
+ // opening, a page pull in flight) is transient: the ladder below keeps asking.
3668
+ // A host that exposes no page state at all is asked as before.
3669
+ //
3670
+ // Measured in the browser: right after a session is (re)opened the face reports
3671
+ // `openState: "loading"` WITH `hasMore: false` — the history window has simply
3672
+ // not been read yet. Taking that as definitive dropped a perfectly good
3673
+ // remembered position (the restore forgot it and left the reader wherever the
3674
+ // app happened to be), so `hasMore === false` only counts once the face says the
3675
+ // session is actually open.
3676
+ var state = { open: face.openState, more: face.hasMore, base: face.baseSeq };
3677
+ var sessionSettled = face.openState === undefined || face.openState === "open";
3678
+ var cannotPage = face.hasMore === false && sessionSettled;
3679
+ if (cannotPage) {
3680
+ console.warn("[dsh-quick-toc] jump loader cannot page to turn " + turn + " (seq " + seq + "): openState="
3681
+ + String(state.open) + ", hasMore=" + String(state.more) + ", baseSeq=" + String(state.base)
3682
+ + " — the outline names more turns than this host will page in");
3683
+ }
2763
3684
  setHoverCard(null);
2764
- Promise.resolve(face.loadThrough(seq)).then(function () {
3685
+ // picking a row in the sheet hands the reader back to the conversation
3686
+ if (sheetOpen) closeSheet();
3687
+ // immediately while a plain page pull owns the busy flag (`loadingOlder`),
3688
+ // and the caller is expected to retry once it settles. That is exactly
3689
+ // "sometimes one click does nothing and a couple of clicks work" — the
3690
+ // clicks that land during an in-flight page are silently discarded. So ask
3691
+ // again on a short ladder: while the loader still says it can page, until
3692
+ // the turn actually lands or a deadline passes.
3693
+ var landed = false;
3694
+ var landingIssued = false;
3695
+ var finish = function () {
3696
+ if (landed) return;
3697
+ landed = true;
2765
3698
  landOnTurn(turn);
2766
- }, function (err) {
2767
- console.warn("[dsh-quick-toc] loadThrough failed:", err);
2768
- });
3699
+ };
3700
+ var giveUp = function () {
3701
+ // a restore is a convenience, not a promise: it never banners, it just
3702
+ // forgets the position so it cannot nag on every open
3703
+ if (quiet) { clearReadPos(sessionId); return; }
3704
+ // last resort, once per session: rebuild the history source and retry
3705
+ if (jumpRetryRef.current) { showBanner(T("turn.loadFailed")); return; }
3706
+ jumpRetryRef.current = true;
3707
+ console.warn("[dsh-quick-toc] turn " + turn + " did not page in; resyncing the history window and retrying once");
3708
+ var rebuilt;
3709
+ try {
3710
+ rebuilt = typeof face.resync === "function" ? face.resync() : null;
3711
+ } catch (e) {
3712
+ rebuilt = Promise.reject(e);
3713
+ }
3714
+ Promise.resolve(rebuilt).then(function () {
3715
+ return face.loadThrough(seq);
3716
+ }).then(function () {
3717
+ landOnTurn(turn);
3718
+ setTimeout(function () {
3719
+ if (!turnLanded(turn)) showBanner(T("turn.loadFailed"));
3720
+ }, 2500);
3721
+ }, function (err) {
3722
+ console.warn("[dsh-quick-toc] resync retry failed:", err);
3723
+ showBanner(T("turn.loadFailed"));
3724
+ });
3725
+ };
3726
+ if (cannotPage) {
3727
+ if (quiet) clearReadPos(sessionId);
3728
+ else showBanner(T("turn.noMoreHistory"));
3729
+ return true;
3730
+ }
3731
+ var deadline = Date.now() + 8000;
3732
+ var ask = function () {
3733
+ if (turnLanded(turn)) { finish(); return; }
3734
+ // same guard as above: a "not open yet" face is asked again rather than
3735
+ // written off (its hasMore is still the placeholder value)
3736
+ var settledNow = face.openState === undefined || face.openState === "open";
3737
+ if (face.hasMore === false && settledNow) {
3738
+ if (quiet) clearReadPos(sessionId);
3739
+ else showBanner(T("turn.noMoreHistory"));
3740
+ return;
3741
+ }
3742
+ if (Date.now() > deadline) { giveUp(); return; }
3743
+ try {
3744
+ Promise.resolve(face.loadThrough(seq)).then(function () {
3745
+ if (turnLanded(turn)) { finish(); return; }
3746
+ // the page is still arriving: let the landing helper start retrying for
3747
+ // the row (it gives up on its own), and keep the ask-ladder running
3748
+ if (!landingIssued) { landingIssued = true; landOnTurn(turn); }
3749
+ }, function (err) {
3750
+ console.warn("[dsh-quick-toc] loadThrough failed:", err);
3751
+ });
3752
+ } catch (e) {
3753
+ console.warn("[dsh-quick-toc] loadThrough threw:", e);
3754
+ }
3755
+ setTimeout(ask, 400);
3756
+ };
3757
+ // first ask immediately (a click must reach the loader in the same task it
3758
+ // happened); the retries ride the ladder
3759
+ ask();
2769
3760
  return true;
2770
3761
  };
2771
3762
 
@@ -2835,14 +3826,15 @@ window.__ModuleLoader__.load({
2835
3826
  };
2836
3827
 
2837
3828
  // ---- circular icon button (glyph optical offset: ox/oy px) ----
2838
- var iconBtn = function (onClick, tip, glyph, fontSize, offset) {
3829
+ var iconBtn = function (onClick, tip, glyph, fontSize, offset, size) {
2839
3830
  var ox = offset ? (offset.x || 0) : 0;
2840
3831
  var oy = offset ? (offset.y || 0) : 0;
3832
+ var box = size || 24;
2841
3833
  return react_jsx_runtime.jsx("button", {
2842
3834
  onClick: onClick,
2843
3835
  style: {
2844
- width: 24,
2845
- height: 24,
3836
+ width: box,
3837
+ height: box,
2846
3838
  display: "flex",
2847
3839
  alignItems: "center",
2848
3840
  justifyContent: "center",
@@ -2885,8 +3877,133 @@ window.__ModuleLoader__.load({
2885
3877
  fuzzy: fuzzy,
2886
3878
  onOpenTurn: openTurn,
2887
3879
  hoverStart: hoverStart,
2888
- hoverEnd: hoverEnd
3880
+ hoverEnd: hoverEnd,
3881
+ // the curtain is wide: rows switch to a one-line, multi-part layout that
3882
+ // puts the section's opening text out to the right instead of under the
3883
+ // title (see renderItem / renderResultRow)
3884
+ wide: sheetOpen
3885
+ };
3886
+
3887
+ // ---- keyboard cursor plumbing -------------------------------------------
3888
+ // Rows are collected from the DOM in document order, so the cursor sequence is
3889
+ // exactly what the reader sees — one rule for the outline, the questions-only
3890
+ // list, the in-session results and the cross-session results alike.
3891
+ var navRows = function () {
3892
+ var el = listRef.current;
3893
+ if (!el || typeof el.querySelectorAll !== "function") return [];
3894
+ var found = el.querySelectorAll("[data-nav-row]");
3895
+ return Array.prototype.slice.call(found);
3896
+ };
3897
+ var markNav = function (el, on) {
3898
+ if (!el) return;
3899
+ if (el.classList && typeof el.classList.toggle === "function") {
3900
+ el.classList.toggle("dqt-nav", on);
3901
+ return;
3902
+ }
3903
+ var cls = String(el.className || "").split(/\s+/).filter(function (c) { return c && c !== "dqt-nav"; });
3904
+ if (on) cls.push("dqt-nav");
3905
+ el.className = cls.join(" ");
2889
3906
  };
3907
+ var rowText = function (el) {
3908
+ if (!el) return "";
3909
+ var text = String(el.textContent || "").replace(/\s+/g, " ").trim();
3910
+ return text.length > 60 ? text.slice(0, 60) + "…" : text;
3911
+ };
3912
+ var scrollRowIntoView = function (el, row) {
3913
+ if (!el || !row || typeof row.getBoundingClientRect !== "function") return;
3914
+ var box = el.getBoundingClientRect();
3915
+ var rr = row.getBoundingClientRect();
3916
+ if (!box || !rr || typeof box.top !== "number") return;
3917
+ // getBoundingClientRect reports VISUAL pixels while scrollTop is layout: the
3918
+ // panel's zoom factor divides the delta (same reasoning as zLen).
3919
+ var factor = zoom > 0 ? zoom : 1;
3920
+ if (rr.top < box.top + 2) el.scrollTop -= (box.top + 2 - rr.top) / factor;
3921
+ else if (rr.bottom > box.bottom - 2) el.scrollTop += (rr.bottom - box.bottom + 2) / factor;
3922
+ };
3923
+ // which row is the reader looking at? (used when the cursor engages, so ↓
3924
+ // continues from the current position instead of restarting at the top)
3925
+ var nearestRowIndex = function (rows) {
3926
+ var el = listRef.current;
3927
+ if (!el || rows.length === 0) return 0;
3928
+ var box = el.getBoundingClientRect();
3929
+ var mid = box && typeof box.top === "number" ? (box.top + box.bottom) / 2 : 0;
3930
+ var best = 0;
3931
+ var bestGap = Infinity;
3932
+ for (var i = 0; i < rows.length; i++) {
3933
+ var rr = rows[i].getBoundingClientRect ? rows[i].getBoundingClientRect() : null;
3934
+ if (!rr || typeof rr.top !== "number") continue;
3935
+ var gap = Math.abs((rr.top + rr.bottom) / 2 - mid);
3936
+ if (gap < bestGap) { bestGap = gap; best = i; }
3937
+ }
3938
+ return best;
3939
+ };
3940
+ var moveNav = function (delta) {
3941
+ var rows = navRows();
3942
+ if (rows.length === 0) return;
3943
+ var next;
3944
+ if (navIdx < 0) next = nearestRowIndex(rows);
3945
+ else next = navIdx + delta;
3946
+ if (next < 0) next = 0;
3947
+ if (next > rows.length - 1) next = rows.length - 1;
3948
+ setNavIdx(next);
3949
+ setLiveText(rowText(rows[next]));
3950
+ };
3951
+ var jumpNav = function () {
3952
+ var rows = navRows();
3953
+ if (rows.length === 0) return;
3954
+ var idx = navIdx < 0 ? nearestRowIndex(rows) : navIdx;
3955
+ if (idx < 0 || idx >= rows.length) return;
3956
+ var row = rows[idx];
3957
+ setNavIdx(idx);
3958
+ setLiveText(rowText(row));
3959
+ if (row && typeof row.click === "function") row.click();
3960
+ };
3961
+ // Consume a key without assuming the event object is a full DOM event (the host
3962
+ // and test harnesses can synthesize partial ones).
3963
+ var swallow = function (e) {
3964
+ if (!e) return;
3965
+ if (typeof e.preventDefault === "function") e.preventDefault();
3966
+ if (typeof e.stopPropagation === "function") e.stopPropagation();
3967
+ };
3968
+ var onPanelKeyDown = function (e) {
3969
+ var target = e.target;
3970
+ var tag = target && target.tagName ? String(target.tagName).toUpperCase() : "";
3971
+ // the search box owns its own keys (it stops propagation for the ones it uses)
3972
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (target && target.isContentEditable)) return;
3973
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
3974
+ var key = e.key;
3975
+ if (key === "ArrowDown" || key === "ArrowUp") {
3976
+ swallow(e);
3977
+ moveNav(key === "ArrowDown" ? 1 : -1);
3978
+ return;
3979
+ }
3980
+ if (key === "Home" || key === "End") {
3981
+ var rows = navRows();
3982
+ if (rows.length === 0) return;
3983
+ swallow(e);
3984
+ var idx = key === "Home" ? 0 : rows.length - 1;
3985
+ setNavIdx(idx);
3986
+ setLiveText(rowText(rows[idx]));
3987
+ return;
3988
+ }
3989
+ if (key === "Enter") {
3990
+ swallow(e);
3991
+ jumpNav();
3992
+ return;
3993
+ }
3994
+ if (key === "Escape") {
3995
+ if (sheetOpen) { swallow(e); closeSheet(); return; }
3996
+ if (!open) return;
3997
+ swallow(e);
3998
+ setNavIdx(-1);
3999
+ setOpen(false);
4000
+ }
4001
+ };
4002
+ // keep the painted cursor on the row the index points at, and keep that row in
4003
+ // view. Runs whenever the row set itself changes (page-in, new results, a mode
4004
+ // switch) so the cursor never survives onto a different row. (The effect itself
4005
+ // lives above the early return, with the other hooks.)
4006
+
2890
4007
  // Transient banner pinned to the bottom of the panel (the panel is
2891
4008
  // position:fixed, so this anchors to the list's lower edge without joining
2892
4009
  // the list's scroll flow). pointerEvents:none keeps it from eating clicks.
@@ -2928,9 +4045,13 @@ window.__ModuleLoader__.load({
2928
4045
  // while `zoom` itself does not animate: leaving `left` in the transition on that
2929
4046
  // one render would let the panel slide across the screen. It lands on exactly the
2930
4047
  // same visual spot either way, so that render simply skips the transition.
2931
- var slide = open ? "0.6s " + EASE : "0.28s " + EASE;
4048
+ // The tuck is a departure even though `open` is still true, so it has to move
4049
+ // at the CLOSING speed: with the opening 0.6s curve the panel was still
4050
+ // travelling when the curtain took over, which looked like it flashed away.
4051
+ var panelAway = !open || panelAside === "tuck";
4052
+ var slide = panelAway ? "0.28s " + EASE : "0.6s " + EASE;
2932
4053
  var panelTransition = (zoomJustChanged ? "" : "left " + slide + ", ")
2933
- + "clip-path " + slide + ", opacity " + (open ? "0.45s ease" : "0.14s ease 0.26s");
4054
+ + "clip-path " + slide + ", opacity " + (panelAway ? "0.14s ease 0.26s" : "0.45s ease");
2934
4055
 
2935
4056
  // clip the panel at the dock edge while collapsed, so sliding away looks
2936
4057
  // like being covered by the sidebar (the sidebar stays untouched):
@@ -2939,7 +4060,7 @@ window.__ModuleLoader__.load({
2939
4060
  // LEFT dock keeps its original clip (collapse left). RIGHT dock clips the
2940
4061
  // LEFT side so the panel closes toward its right (dock) edge in place.
2941
4062
  var panelClip = "inset(0 0 0 0px)";
2942
- if (!open) {
4063
+ if (!geoOpen) {
2943
4064
  if (dockRight) {
2944
4065
  // mirror of LEFT: clip the RIGHT side (panel flies right, shrinking from its right)
2945
4066
  panelClip = "inset(0 " + zLen(panelW) + " 0 0)";
@@ -2955,277 +4076,415 @@ window.__ModuleLoader__.load({
2955
4076
  // renderGroups) instead of from fading everything else away.
2956
4077
  var panelOpacity = open && chatViewActive ? (hovered ? 1 : 0.72) : 0;
2957
4078
 
2958
- var panelEl = react_jsx_runtime.jsx("div", {
2959
- ref: panelRootRef,
2960
- style: {
2961
- position: "fixed",
2962
- top: zLen(baseTopPx),
2963
- left: zLen(panelLeft),
2964
- width: zLen(panelW),
2965
- maxHeight: zLen(vMaxH),
2966
- height: panelH > 0 ? zLen(panelH) : undefined,
2967
- zoom: zoom === 1 ? undefined : zoom,
2968
- display: "flex",
2969
- flexDirection: "column",
2970
- background: C.panelBg,
2971
- border: "none",
2972
- borderRadius: 12,
2973
- zIndex: Z_BASE,
2974
- overflow: "hidden",
2975
- color: C.text,
2976
- opacity: panelOpacity,
2977
- clipPath: panelClip,
2978
- // inner shadow only (no outer): theme-aware (white bevel in dark mode,
2979
- // soft shade in light) — outer shadow would be clipped by the collapse
2980
- // clip-path and the panel edges.
2981
- boxShadow: innerShadow(),
2982
- // A view switch swaps in a short, delay-free opacity fade; the dock and
2983
- // collapse animations keep their own (slower) timings.
2984
- transition: viewFading ? "opacity 0.3s ease" : panelTransition,
2985
- pointerEvents: chatViewActive ? "auto" : "none"
2986
- },
2987
- onMouseEnter: function () { setHovered(true); },
2988
- onMouseLeave: function () { setHovered(false); },
2989
- onClickCapture: function (e) {
2990
- // jump handled in the capture phase (bubble-phase handlers are unreliable here)
2991
- var t = e.target;
2992
- var item = (t && t.closest) ? t.closest("[data-jump-key]") : null;
2993
- if (item && item.dataset && item.dataset.jumpKey) {
2994
- e.preventDefault();
2995
- e.stopPropagation();
2996
- var idx = item.dataset.jumpIdx !== undefined ? Number(item.dataset.jumpIdx) : 0;
2997
- jump(item.dataset.jumpKey, idx);
2998
- }
2999
- },
3000
- children: [
3001
- // top block: small grip bar on top + header row below (whole block draggable)
3002
- react_jsx_runtime.jsx("div", {
3003
- style: {
3004
- flex: "none",
3005
- cursor: "grab",
3006
- userSelect: "none"
3007
- },
3008
- title: T("handle.dragY"),
3009
- onPointerDown: onHandleDown,
3010
- onMouseEnter: function () { handleBright(true); },
3011
- onMouseLeave: function () { handleBright(false); },
3012
- children: [
4079
+ // ---- toolbar buttons -----------------------------------------------------
4080
+ // ONE instance of each, placed either in the docked header row or in the
4081
+ // curtain's own chrome. On the curtain they are larger (that surface has the
4082
+ // room), and the dock / curtain / collapse controls are not built at all —
4083
+ // they mean nothing on a panel that already spans the conversation.
4084
+ var tbSize = sheetOpen ? 32 : 24;
4085
+ var tbIcon = sheetOpen ? 18 : 14;
4086
+ var tbIconLg = sheetOpen ? 20 : 15;
4087
+ var toolBtn = function (tool, className, onClick, title, active, disabled, svg, extra) {
4088
+ var props = {
4089
+ className: className,
4090
+ onClick: onClick,
4091
+ title: title,
4092
+ "data-tool": tool,
4093
+ style: {
4094
+ width: tbSize,
4095
+ height: tbSize,
4096
+ padding: 0,
4097
+ border: "none",
4098
+ borderRadius: "50%",
4099
+ cornerShape: "round",
4100
+ flex: "none",
4101
+ display: "flex",
4102
+ alignItems: "center",
4103
+ justifyContent: "center",
4104
+ cursor: disabled ? "default" : "pointer",
4105
+ opacity: disabled ? 0.45 : 1,
4106
+ // open (or still animating shut) uses the SAME tint as every other
4107
+ // "on" control, so the toolbar reads consistently in both layouts
4108
+ background: active ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
4109
+ color: active ? C.accent : C.muted,
4110
+ transition: "background 0.15s ease, color 0.15s ease, opacity 0.2s ease"
4111
+ },
4112
+ onMouseEnter: function (e) {
4113
+ if (disabled) return;
4114
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
4115
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
4116
+ },
4117
+ onMouseLeave: function (e) {
4118
+ e.currentTarget.style.background = active ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
4119
+ e.currentTarget.style.color = active ? C.accent : C.muted;
4120
+ },
4121
+ children: svg
4122
+ };
4123
+ // always present (false, not undefined): the toolbar reads it to decide the
4124
+ // resting cursor/opacity, and the tests assert the false case explicitly
4125
+ props.disabled = !!disabled;
4126
+ if (extra) {
4127
+ for (var k in extra) {
4128
+ if (Object.prototype.hasOwnProperty.call(extra, k)) props[k] = extra[k];
4129
+ }
4130
+ }
4131
+ return react_jsx_runtime.jsx("button", props);
4132
+ };
4133
+ // heading-level filter: a round icon button that pops down the H1–H6 picker.
4134
+ // Icon: three lines of decreasing width = outline levels.
4135
+ var levelsBtnEl = toolBtn("levels", "dqt-levels-btn",
4136
+ function () { if (questionsOnly) return; levelsOpen ? closeLevels() : setLevelsOpen(true); },
4137
+ questionsOnly ? T("levels.offInQuestions") : T("levels.tip"),
4138
+ (levelsOpen || levelsClosing), questionsOnly,
4139
+ react_jsx_runtime.jsx("svg", {
4140
+ width: tbIcon,
4141
+ height: tbIcon,
4142
+ viewBox: "0 0 14 14",
4143
+ fill: "none",
4144
+ stroke: "currentColor",
4145
+ strokeWidth: 1.8,
4146
+ strokeLinecap: "round",
4147
+ style: { display: "block" },
4148
+ children: [
4149
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 3, x2: 12.5, y2: 3 }),
4150
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 7, x2: 9, y2: 7 }),
4151
+ react_jsx_runtime.jsx("line", { x1: 1.5, y1: 11, x2: 5.5, y2: 11 })
4152
+ ]
4153
+ })
4154
+ );
4155
+ // questions-only toggle: icon = a speech bubble (the prompt), which is exactly
4156
+ // what that view keeps
4157
+ var questionsBtnEl = toolBtn("questions", "dqt-questions-btn", toggleQuestions,
4158
+ questionsOnly ? T("view.questions.tipOn") : T("view.questions.tipOff"),
4159
+ questionsOnly, false,
4160
+ react_jsx_runtime.jsx("svg", {
4161
+ width: tbIcon,
4162
+ height: tbIcon,
4163
+ viewBox: "0 0 14 14",
4164
+ fill: "none",
4165
+ stroke: "currentColor",
4166
+ strokeWidth: 1.8,
4167
+ strokeLinecap: "round",
4168
+ strokeLinejoin: "round",
4169
+ style: { display: "block" },
4170
+ children: [
4171
+ react_jsx_runtime.jsx("rect", { x: 1.6, y: 2.4, width: 10.8, height: 7.2, rx: 1.6 }),
4172
+ react_jsx_runtime.jsx("path", { d: "M4.6 9.6v2.6l2.8-2.6" })
4173
+ ]
4174
+ }),
4175
+ { "data-questions": questionsOnly ? "on" : "off" }
4176
+ );
4177
+ // magnifier (SVG, matches the other buttons' style)
4178
+ var searchBtnEl = toolBtn("search", "dqt-search-btn",
4179
+ function () { searchOpen ? closeSearch() : openSearch(); },
4180
+ T("search.open"), searchOpen, false,
4181
+ react_jsx_runtime.jsx("svg", {
4182
+ width: tbIconLg,
4183
+ height: tbIconLg,
4184
+ viewBox: "0 0 24 24",
4185
+ // overflow visible: the stroke may extend past the
4186
+ // viewBox without being clipped at the svg boundary
4187
+ style: { display: "block", transform: "translate(-1px, -1px)", overflow: "visible" },
4188
+ fill: "none",
4189
+ stroke: "currentColor",
4190
+ strokeWidth: 3.6,
4191
+ strokeLinecap: "round",
4192
+ children: [
4193
+ react_jsx_runtime.jsx("circle", { cx: 11, cy: 11, r: 7 }),
4194
+ react_jsx_runtime.jsx("line", { x1: 21, y1: 21, x2: 16, y2: 16 })
4195
+ ]
4196
+ })
4197
+ );
4198
+ // the curtain's way out: the SAME thick SVG cross as the docked header's
4199
+ // collapse button, scaled with the rest of the curtain's buttons
4200
+ var sheetCloseBtnEl = toolBtn("close", "dqt-sheet-close-btn", closeSheet, T("sheet.close"), false, false,
4201
+ react_jsx_runtime.jsx("svg", {
4202
+ width: tbIconLg,
4203
+ height: tbIconLg,
4204
+ viewBox: "0 0 24 24",
4205
+ style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
4206
+ fill: "none",
4207
+ stroke: "currentColor",
4208
+ strokeWidth: 3.4,
4209
+ strokeLinecap: "round",
4210
+ children: [
4211
+ // cross lines span 5..19, intersection (12,12) centered
4212
+ react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
4213
+ react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
4214
+ ]
4215
+ })
4216
+ );
4217
+ // heading-level picker: pops down from the round toolbar button. It is anchored
4218
+ // to the toolbar itself on the curtain (no overflow-clipped row there) and to
4219
+ // the panel box in the docked layout (the header row scrolls sideways, so a
4220
+ // child of it would be clipped). Open/close animate from the button's center;
4221
+ // the animation lives in the injected stylesheet (classes, not inline
4222
+ // animation) so React re-renders never restart or cancel it.
4223
+ var buildLevelsPop = function (inSheet) {
4224
+ // on the curtain the six levels are ONE spread-out row (there is room);
4225
+ // in the narrow docked panel they have to wrap, and they are capped by the
4226
+ // panel's own width rather than by the shrink-to-fit toolbar wrapper
4227
+ var popW = inSheet ? "min(336px, 74vw)" : undefined;
4228
+ return react_jsx_runtime.jsx("div", {
4229
+ className: "dqt-levels-pop" + (levelsClosing ? " dqt-levels-pop-closing" : ""),
4230
+ style: {
4231
+ position: "absolute",
4232
+ top: inSheet ? "calc(100% + 8px)" : 40,
4233
+ left: inSheet ? 0 : 10,
4234
+ zIndex: 30,
4235
+ display: "flex",
4236
+ alignItems: "center",
4237
+ flexWrap: inSheet ? "nowrap" : "wrap",
4238
+ gap: 4,
4239
+ padding: 8,
4240
+ background: C.panelBg,
4241
+ border: "1px solid " + C.panelBorder,
4242
+ borderRadius: 10,
4243
+ cornerShape: "round",
4244
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
4245
+ width: popW,
4246
+ maxWidth: inSheet ? popW : "calc(100% - 20px)",
4247
+ // the button's center sits 12px (dock) / 16px (curtain) inside the popup
4248
+ transformOrigin: (inSheet ? tbSize / 2 : 12) + "px top"
4249
+ },
4250
+ children: [1, 2, 3, 4, 5, 6].map(function (lv) {
4251
+ var active = !!levelSet[lv];
4252
+ return react_jsx_runtime.jsx("button", {
4253
+ onClick: function () { toggleLevel(lv); },
4254
+ title: active ? T("levels.hide") + lv : T("levels.show") + lv,
4255
+ style: {
4256
+ height: inSheet ? 32 : 22,
4257
+ flex: inSheet ? "1 1 0" : "0 0 auto",
4258
+ minWidth: 0,
4259
+ padding: inSheet ? 0 : "0 7px",
4260
+ border: "none",
4261
+ borderRadius: 6,
4262
+ cornerShape: "round",
4263
+ cursor: "pointer",
4264
+ fontSize: inSheet ? 14.5 : 11,
4265
+ lineHeight: (inSheet ? 32 : 22) + "px",
4266
+ fontFamily: "inherit",
4267
+ background: active
4268
+ ? C.chip
4269
+ : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
4270
+ color: active ? C.accent : C.muted,
4271
+ fontWeight: active ? 600 : 400
4272
+ },
4273
+ children: "H" + lv
4274
+ }, "lv" + lv);
4275
+ })
4276
+ });
4277
+ };
4278
+ var levelsPopDockEl = (!sheetOpen && (levelsOpen || levelsClosing)) ? buildLevelsPop(false) : null;
4279
+ var levelsPopSheetEl = (sheetOpen && (levelsOpen || levelsClosing)) ? buildLevelsPop(true) : null;
4280
+ // dock toggle: the triangle tips toward the side it will move TO
4281
+ var dockBtnEl = iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 });
4282
+ // the curtain button: a surface with an arrow rising out of it
4283
+ var sheetBtnEl = react_jsx_runtime.jsx("button", {
4284
+ className: "dqt-sheet-btn",
4285
+ onClick: openSheet,
4286
+ title: T("sheet.open"),
4287
+ style: {
4288
+ width: 24,
4289
+ height: 24,
4290
+ padding: 0,
4291
+ display: "flex",
4292
+ alignItems: "center",
4293
+ justifyContent: "center",
4294
+ borderRadius: "50%",
4295
+ cornerShape: "round",
4296
+ border: "none",
4297
+ flex: "none",
4298
+ cursor: "pointer",
4299
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
4300
+ color: C.muted,
4301
+ transition: "background 0.15s ease, color 0.15s ease, opacity 0.2s ease"
4302
+ },
4303
+ onMouseEnter: function (e) {
4304
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
4305
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
4306
+ },
4307
+ onMouseLeave: function (e) {
4308
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
4309
+ e.currentTarget.style.color = C.muted;
4310
+ },
4311
+ children: react_jsx_runtime.jsx("svg", {
4312
+ width: 14,
4313
+ height: 14,
4314
+ viewBox: "0 0 14 14",
4315
+ fill: "none",
4316
+ stroke: "currentColor",
4317
+ strokeWidth: 1.8,
4318
+ strokeLinecap: "round",
4319
+ strokeLinejoin: "round",
4320
+ style: { display: "block" },
4321
+ children: [
4322
+ react_jsx_runtime.jsx("rect", { x: 1.6, y: 3.2, width: 10.8, height: 9.2, rx: 1.6 }),
4323
+ react_jsx_runtime.jsx("path", { d: "M7 9.6V5.6M4.9 7.7L7 5.6l2.1 2.1" })
4324
+ ]
4325
+ })
4326
+ });
4327
+ // collapse (docked panel only): thick SVG cross, nudged slightly down
4328
+ var headerCloseBtnEl = react_jsx_runtime.jsx("button", {
4329
+ onClick: function () { setOpen(false); },
4330
+ title: T("handle.collapse"),
4331
+ style: {
4332
+ width: 24,
4333
+ height: 24,
4334
+ display: "flex",
4335
+ alignItems: "center",
4336
+ justifyContent: "center",
4337
+ borderRadius: "50%",
4338
+ cornerShape: "round",
4339
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
4340
+ border: "none",
4341
+ color: C.muted,
4342
+ cursor: "pointer"
4343
+ },
4344
+ onMouseEnter: function (e) {
4345
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
4346
+ e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
4347
+ },
4348
+ onMouseLeave: function (e) {
4349
+ e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
4350
+ e.currentTarget.style.color = C.muted;
4351
+ },
4352
+ children: react_jsx_runtime.jsx("svg", {
4353
+ width: 15,
4354
+ height: 15,
4355
+ viewBox: "0 0 24 24",
4356
+ style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
4357
+ fill: "none",
4358
+ stroke: "currentColor",
4359
+ strokeWidth: 3.4,
4360
+ strokeLinecap: "round",
4361
+ children: [
4362
+ // cross lines span 5..19, intersection (12,12) centered
4363
+ react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
4364
+ react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
4365
+ ]
4366
+ })
4367
+ });
4368
+
4369
+ // ---- docked header: grip bar on top + the header row (whole block draggable)
4370
+ var dockHeaderEl = react_jsx_runtime.jsx("div", {
4371
+ style: {
4372
+ flex: "none",
4373
+ cursor: "grab",
4374
+ userSelect: "none"
4375
+ },
4376
+ title: T("handle.dragY"),
4377
+ onPointerDown: onHandleDown,
4378
+ onMouseEnter: function () { handleBright(true); },
4379
+ onMouseLeave: function () { handleBright(false); },
4380
+ children: [
4381
+ react_jsx_runtime.jsx("div", {
4382
+ ref: handleRef,
4383
+ style: {
4384
+ width: 56,
4385
+ maxWidth: "calc(100% - 12px)",
4386
+ height: 5,
4387
+ borderRadius: 999,
4388
+ cornerShape: "round",
4389
+ background: "var(--dsw-alias-border-l2, rgba(128,128,128,0.55))",
4390
+ // no slack left in the header -> the grab hint fades away
4391
+ opacity: crowded ? 0 : 0.35,
4392
+ transition: "opacity 0.2s ease",
4393
+ margin: "5px auto 2px"
4394
+ }
4395
+ }),
4396
+ react_jsx_runtime.jsx("div", {
4397
+ ref: headerRef,
4398
+ className: "dqt-header",
4399
+ style: {
4400
+ display: "flex",
4401
+ alignItems: "center",
4402
+ justifyContent: "space-between",
4403
+ padding: "1px 10px 5px",
4404
+ borderBottom: "1px solid " + C.panelBorder,
4405
+ fontSize: 13,
4406
+ fontWeight: 600,
4407
+ gap: 6,
4408
+ // the controls keep their size and stay on ONE line; when the row
4409
+ // runs out of room it scrolls sideways (wheel) instead of wrapping or
4410
+ // overlapping, and the flexible spacer below collapses first
4411
+ flexWrap: "nowrap",
4412
+ overflowX: "auto",
4413
+ overflowY: "hidden",
4414
+ scrollbarWidth: "none"
4415
+ },
4416
+ children: [
4417
+ // LEFT pair: the heading-level filter and the dock toggle take the
4418
+ // left end — so the header reads two controls per side (search +
4419
+ // collapse sit at the other end).
3013
4420
  react_jsx_runtime.jsx("div", {
3014
- ref: handleRef,
3015
- style: {
3016
- width: 56,
3017
- maxWidth: "calc(100% - 12px)",
3018
- height: 5,
3019
- borderRadius: 999,
3020
- cornerShape: "round",
3021
- background: "var(--dsw-alias-border-l2, rgba(128,128,128,0.55))",
3022
- // no slack left in the header -> the grab hint fades away
3023
- opacity: crowded ? 0 : 0.35,
3024
- transition: "opacity 0.2s ease",
3025
- margin: "5px auto 2px"
3026
- }
4421
+ style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
4422
+ children: [levelsBtnEl, questionsBtnEl, dockBtnEl]
3027
4423
  }),
4424
+ // spacer: nothing here, it just pins the two pairs to the two ends —
4425
+ // and it is the first thing to give way when the panel narrows
4426
+ react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto", minWidth: 0 } }),
4427
+ // RIGHT pair: search + curtain + collapse
3028
4428
  react_jsx_runtime.jsx("div", {
3029
- ref: headerRef,
3030
- className: "dqt-header",
3031
- style: {
3032
- display: "flex",
3033
- alignItems: "center",
3034
- justifyContent: "space-between",
3035
- padding: "1px 10px 5px",
3036
- borderBottom: "1px solid " + C.panelBorder,
3037
- fontSize: 13,
3038
- fontWeight: 600,
3039
- gap: 6,
3040
- // the four controls keep their size and stay on ONE line; when the row
3041
- // runs out of room it scrolls sideways (wheel) instead of wrapping or
3042
- // overlapping, and the flexible spacer below collapses first
3043
- flexWrap: "nowrap",
3044
- overflowX: "auto",
3045
- overflowY: "hidden",
3046
- scrollbarWidth: "none"
3047
- },
3048
- children: [
3049
- // LEFT pair: the heading-level filter and the dock toggle take the
3050
- // left end — so the header reads two controls per side (search +
3051
- // collapse sit at the other end).
3052
- react_jsx_runtime.jsx("div", {
3053
- style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
3054
- children: [
3055
- // heading-level filter: a round icon button that pops down the
3056
- // H1–H6 picker. Icon: three lines of decreasing width = outline
3057
- // levels. Hover behaviour matches the magnifier: background +
3058
- // icon tint change; the resting look never shifts when open.
3059
- react_jsx_runtime.jsx("button", {
3060
- className: "dqt-levels-btn",
3061
- onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
3062
- title: T("levels.tip"),
3063
- style: {
3064
- width: 24,
3065
- height: 24,
3066
- padding: 0,
3067
- border: "none",
3068
- borderRadius: "50%",
3069
- cornerShape: "round",
3070
- cursor: "pointer",
3071
- flex: "none",
3072
- display: "flex",
3073
- alignItems: "center",
3074
- justifyContent: "center",
3075
- // open (or still animating shut) uses the SAME tint as every
3076
- // other "on" control, so the header reads consistently
3077
- background: (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
3078
- color: (levelsOpen || levelsClosing) ? C.accent : C.muted,
3079
- transition: "background 0.15s ease, color 0.15s ease"
3080
- },
3081
- onMouseEnter: function (e) {
3082
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
3083
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
3084
- },
3085
- onMouseLeave: function (e) {
3086
- e.currentTarget.style.background = (levelsOpen || levelsClosing) ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
3087
- e.currentTarget.style.color = (levelsOpen || levelsClosing) ? C.accent : C.muted;
3088
- },
3089
- children: react_jsx_runtime.jsx("svg", {
3090
- width: 14,
3091
- height: 14,
3092
- viewBox: "0 0 14 14",
3093
- fill: "none",
3094
- stroke: "currentColor",
3095
- strokeWidth: 1.8,
3096
- strokeLinecap: "round",
3097
- style: { display: "block" },
3098
- children: [
3099
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 3, x2: 12.5, y2: 3 }),
3100
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 7, x2: 9, y2: 7 }),
3101
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 11, x2: 5.5, y2: 11 })
3102
- ]
3103
- })
3104
- }),
3105
- // dock toggle: the triangle tips toward the side it will move TO
3106
- iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockRight ? "◀" : "▶", 12, dockRight ? { x: -1, y: -1 } : { x: 1, y: -1 })
3107
- ]
3108
- }),
3109
- // spacer: nothing here, it just pins the two pairs to the two ends —
3110
- // and it is the first thing to give way when the panel narrows
3111
- react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto", minWidth: 0 } }),
3112
- // RIGHT pair: search + collapse
3113
- react_jsx_runtime.jsx("div", {
3114
- style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
3115
- children: [
3116
- // magnifier button (SVG, matches the other buttons' style)
3117
- react_jsx_runtime.jsx("button", {
3118
- onClick: function () { searchOpen ? closeSearch() : openSearch(); },
3119
- title: T("search.open"),
3120
- style: {
3121
- width: 24,
3122
- height: 24,
3123
- display: "flex",
3124
- alignItems: "center",
3125
- justifyContent: "center",
3126
- borderRadius: "50%",
3127
- cornerShape: "round",
3128
- background: searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
3129
- border: "none",
3130
- color: searchOpen ? C.accent : C.muted,
3131
- cursor: "pointer"
3132
- },
3133
- onMouseEnter: function (e) {
3134
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
3135
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
3136
- },
3137
- onMouseLeave: function (e) {
3138
- e.currentTarget.style.background = searchOpen ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
3139
- e.currentTarget.style.color = searchOpen ? C.accent : C.muted;
3140
- },
3141
- children: react_jsx_runtime.jsx("svg", {
3142
- width: 15,
3143
- height: 15,
3144
- viewBox: "0 0 24 24",
3145
- // overflow visible: the stroke may extend past the
3146
- // viewBox without being clipped at the svg boundary
3147
- style: { display: "block", transform: "translate(-1px, -1px)", overflow: "visible" },
3148
- fill: "none",
3149
- stroke: "currentColor",
3150
- strokeWidth: 3.6,
3151
- strokeLinecap: "round",
3152
- children: [
3153
- react_jsx_runtime.jsx("circle", { cx: 11, cy: 11, r: 7 }),
3154
- react_jsx_runtime.jsx("line", { x1: 21, y1: 21, x2: 16, y2: 16 })
3155
- ]
3156
- })
3157
- }),
3158
- // close: thick SVG cross, nudged slightly down
3159
- react_jsx_runtime.jsx("button", {
3160
- onClick: function () { setOpen(false); },
3161
- title: T("handle.collapse"),
3162
- style: {
3163
- width: 24,
3164
- height: 24,
3165
- display: "flex",
3166
- alignItems: "center",
3167
- justifyContent: "center",
3168
- borderRadius: "50%",
3169
- cornerShape: "round",
3170
- background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
3171
- border: "none",
3172
- color: C.muted,
3173
- cursor: "pointer"
3174
- },
3175
- onMouseEnter: function (e) {
3176
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
3177
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
3178
- },
3179
- onMouseLeave: function (e) {
3180
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
3181
- e.currentTarget.style.color = C.muted;
3182
- },
3183
- children: react_jsx_runtime.jsx("svg", {
3184
- width: 15,
3185
- height: 15,
3186
- viewBox: "0 0 24 24",
3187
- style: { display: "block", transform: "translate(1px, 0px)", overflow: "visible" },
3188
- fill: "none",
3189
- stroke: "currentColor",
3190
- strokeWidth: 3.4,
3191
- strokeLinecap: "round",
3192
- children: [
3193
- // cross lines span 5..19, intersection (12,12) centered
3194
- react_jsx_runtime.jsx("line", { x1: 5, y1: 5, x2: 19, y2: 19 }),
3195
- react_jsx_runtime.jsx("line", { x1: 19, y1: 5, x2: 5, y2: 19 })
3196
- ]
3197
- })
3198
- })
3199
- ]
3200
- })
3201
- ]
4429
+ style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
4430
+ children: [searchBtnEl, sheetBtnEl, headerCloseBtnEl]
3202
4431
  })
3203
4432
  ]
4433
+ })
4434
+ ]
4435
+ });
4436
+
4437
+ // ---- curtain chrome: the controls that still apply here (the level filter,
4438
+ // questions-only, search) plus the way out. There is no title — the surface is
4439
+ // self-evident — and no hint text: ✕ and Esc are the obvious exits. The dock /
4440
+ // curtain / collapse buttons are absent on purpose: there is no docked panel to
4441
+ // move and no second curtain to open.
4442
+ var sheetChromeEl = react_jsx_runtime.jsx("div", {
4443
+ className: "dqt-sheet-chrome",
4444
+ title: T("sheet.noDrag"),
4445
+ style: {
4446
+ flex: "none",
4447
+ display: "flex",
4448
+ alignItems: "center",
4449
+ gap: 10,
4450
+ padding: "7px 12px",
4451
+ borderBottom: "1px solid " + C.panelBorder
4452
+ },
4453
+ children: [
4454
+ react_jsx_runtime.jsx("div", {
4455
+ style: { position: "relative", display: "flex", alignItems: "center", gap: 8, flex: "none" },
4456
+ children: [levelsBtnEl, questionsBtnEl, levelsPopSheetEl]
3204
4457
  }),
3205
- // search input row: outer grid row animates 0fr<->1fr so the row
3206
- // collapses/expands to its EXACT natural height (no max-height
3207
- // overshoot stutter); the outline below moves smoothly with it
3208
- (searchOpen || searchAnim === "out") ? react_jsx_runtime.jsx("div", {
4458
+ react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto", minWidth: 0 } }),
4459
+ searchBtnEl,
4460
+ sheetCloseBtnEl
4461
+ ]
4462
+ });
4463
+
4464
+ // ---- search row: the input plus the scope pill, the fuzzy switch and the
4465
+ // match counter. In the docked layout it is a row under the header; on the
4466
+ // curtain it is the head of the search column on the right.
4467
+ var searchRowEl = (searchOpen || searchAnim === "out") ? react_jsx_runtime.jsx("div", {
4468
+ style: {
4469
+ display: "grid",
4470
+ gridTemplateRows: (searchAnim === "enter" || searchAnim === "out") ? "0fr" : "1fr",
4471
+ transition: "grid-template-rows 0.2s " + EASE,
4472
+ flex: "none"
4473
+ },
4474
+ children: react_jsx_runtime.jsx("div", {
4475
+ style: { overflow: "hidden", minHeight: 0 },
4476
+ children: react_jsx_runtime.jsx("div", {
3209
4477
  style: {
3210
- display: "grid",
3211
- gridTemplateRows: (searchAnim === "enter" || searchAnim === "out") ? "0fr" : "1fr",
3212
- transition: "grid-template-rows 0.2s " + EASE,
3213
- flex: "none"
4478
+ display: "flex",
4479
+ alignItems: "center",
4480
+ gap: sheetOpen ? 6 : 4,
4481
+ padding: sheetOpen ? "4px 10px 8px" : "2px 8px 4px",
4482
+ borderBottom: "1px solid " + C.panelBorder,
4483
+ opacity: (searchAnim === "enter" || searchAnim === "out") ? 0 : 1,
4484
+ transform: (searchAnim === "enter" || searchAnim === "out") ? "translateY(-6px)" : "none",
4485
+ transition: "opacity 0.2s ease, transform 0.2s ease"
3214
4486
  },
3215
- children: react_jsx_runtime.jsx("div", {
3216
- style: { overflow: "hidden", minHeight: 0 },
3217
- children: react_jsx_runtime.jsx("div", {
3218
- style: {
3219
- display: "flex",
3220
- alignItems: "center",
3221
- gap: 4,
3222
- padding: "2px 8px 4px",
3223
- borderBottom: "1px solid " + C.panelBorder,
3224
- opacity: (searchAnim === "enter" || searchAnim === "out") ? 0 : 1,
3225
- transform: (searchAnim === "enter" || searchAnim === "out") ? "translateY(-6px)" : "none",
3226
- transition: "opacity 0.2s ease, transform 0.2s ease"
3227
- },
3228
- children: [
4487
+ children: [
3229
4488
  // input with a custom animated placeholder: the box stays static,
3230
4489
  // only the placeholder text fades when the scope switches
3231
4490
  react_jsx_runtime.jsx("div", {
@@ -3238,7 +4497,7 @@ window.__ModuleLoader__.load({
3238
4497
  background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
3239
4498
  borderRadius: 999,
3240
4499
  cornerShape: "round",
3241
- padding: "6px 12px"
4500
+ padding: sheetOpen ? "8px 14px" : "6px 12px"
3242
4501
  },
3243
4502
  children: [
3244
4503
  react_jsx_runtime.jsx("input", {
@@ -3246,8 +4505,12 @@ window.__ModuleLoader__.load({
3246
4505
  value: query,
3247
4506
  onChange: function (e) { onQueryChange(e.target.value); },
3248
4507
  onKeyDown: function (e) {
3249
- if (e.key === "Enter") { goToMatch(matchIdx + 1); e.preventDefault(); }
3250
- if (e.key === "Escape") { closeSearch(); }
4508
+ // ↑/↓ step the in-session matches, exactly like Enter does
4509
+ // (arrows only mean something while a query is typed)
4510
+ if (e.key === "ArrowDown") { goToMatch(matchIdx + 1); swallow(e); return; }
4511
+ if (e.key === "ArrowUp") { goToMatch(matchIdx - 1); swallow(e); return; }
4512
+ if (e.key === "Enter") { goToMatch(matchIdx + 1); swallow(e); return; }
4513
+ if (e.key === "Escape") { closeSearch(); swallow(e); }
3251
4514
  },
3252
4515
  style: {
3253
4516
  flex: 1,
@@ -3256,7 +4519,7 @@ window.__ModuleLoader__.load({
3256
4519
  background: "transparent",
3257
4520
  border: "none",
3258
4521
  outline: "none",
3259
- fontSize: 13,
4522
+ fontSize: sheetOpen ? 16 : 13,
3260
4523
  color: C.text,
3261
4524
  padding: 0
3262
4525
  }
@@ -3264,9 +4527,9 @@ window.__ModuleLoader__.load({
3264
4527
  (!query) ? react_jsx_runtime.jsx("span", {
3265
4528
  style: {
3266
4529
  position: "absolute",
3267
- left: 12,
4530
+ left: sheetOpen ? 14 : 12,
3268
4531
  pointerEvents: "none",
3269
- fontSize: 13,
4532
+ fontSize: sheetOpen ? 16 : 13,
3270
4533
  color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
3271
4534
  },
3272
4535
  children: [
@@ -3277,21 +4540,21 @@ window.__ModuleLoader__.load({
3277
4540
  react_jsx_runtime.jsx("span", {
3278
4541
  key: searchScope,
3279
4542
  style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
3280
- children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
4543
+ children: scopeLabel(searchScope)
3281
4544
  }),
3282
4545
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
3283
4546
  key: "old-" + prevScope,
3284
4547
  style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
3285
- children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
4548
+ children: scopeLabel(prevScope)
3286
4549
  }) : null
3287
4550
  ]
3288
4551
  }),
3289
4552
  T("search.tail")
3290
4553
  ]
3291
- }) : null,
4554
+ }) : null
3292
4555
  ]
3293
4556
  }),
3294
- searching ? react_jsx_runtime.jsx("span", {
4557
+ (searching || (searchScope === "cross" && cross.phase === "loading")) ? react_jsx_runtime.jsx("span", {
3295
4558
  style: { width: 13, height: 13, flex: "none", display: "flex", alignItems: "center", justifyContent: "center" },
3296
4559
  children: react_jsx_runtime.jsx("svg", {
3297
4560
  width: 12,
@@ -3309,25 +4572,30 @@ window.__ModuleLoader__.load({
3309
4572
  })
3310
4573
  }) : react_jsx_runtime.jsx("span", {
3311
4574
  style: { fontSize: 11, color: C.muted, flex: "none", minWidth: 30, textAlign: "center" },
3312
- children: matches.length > 0 ? ((matchIdx % matches.length) + 1) + "/" + matches.length : ""
4575
+ children: searchScope === "cross"
4576
+ ? (cross.phase === "done" && cross.rows.length > 0 ? String(cross.rows.length) + (cross.more ? "+" : "") : "")
4577
+ : (matches.length > 0 ? ((matchIdx % matches.length) + 1) + "/" + matches.length : "")
3313
4578
  }),
3314
- // scope toggle: 标题 <-> 全文 (round pill; background fades,
3315
- // label text cross-fades old->new)
4579
+ // scope toggle: 标题 -> 全文 -> 会话 (ONE round pill cycling the three
4580
+ // scopes; background fades, label text cross-fades old->new, so a third
4581
+ // scope costs no extra button)
3316
4582
  react_jsx_runtime.jsx("button", {
3317
4583
  onClick: toggleScope,
3318
- title: searchScope === "title" ? T("search.scope.tipTitle") : T("search.scope.tipFull"),
4584
+ "data-scope": searchScope,
4585
+ title: searchScope === "title" ? T("search.scope.tipTitle")
4586
+ : (searchScope === "full" ? T("search.scope.tipFull") : T("search.scope.tipCross")),
3319
4587
  style: {
3320
4588
  flex: "none",
3321
- width: 34,
3322
- height: 34,
4589
+ width: sheetOpen ? 40 : 34,
4590
+ height: sheetOpen ? 40 : 34,
3323
4591
  display: "flex",
3324
4592
  alignItems: "center",
3325
4593
  justifyContent: "center",
3326
4594
  borderRadius: "50%",
3327
4595
  cornerShape: "round",
3328
- fontSize: 11,
3329
- color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
3330
- background: searchScope === "full" ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
4596
+ fontSize: sheetOpen ? 13 : 11,
4597
+ color: searchScope !== "title" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
4598
+ background: searchScope !== "title" ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
3331
4599
  border: "none",
3332
4600
  cursor: "pointer",
3333
4601
  whiteSpace: "nowrap",
@@ -3339,91 +4607,272 @@ window.__ModuleLoader__.load({
3339
4607
  react_jsx_runtime.jsx("span", {
3340
4608
  key: searchScope,
3341
4609
  style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
3342
- children: searchScope === "title" ? T("search.scope.title") : T("search.scope.full")
4610
+ children: scopeLabel(searchScope)
3343
4611
  }),
3344
4612
  (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
3345
4613
  key: "old-" + prevScope,
3346
4614
  style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
3347
- children: prevScope === "title" ? T("search.scope.title") : T("search.scope.full")
4615
+ children: scopeLabel(prevScope)
3348
4616
  }) : null
3349
4617
  ]
3350
4618
  })
3351
4619
  }),
3352
4620
  // fuzzy switch: an independent toggle beside the scope pill (its label
3353
- // is the same either way, so only the colour carries the state)
4621
+ // is the same either way, so only the colour carries the state). It means
4622
+ // nothing in cross-session scope — the HOST searches literal phrases —
4623
+ // so there it is disabled and its tip says exactly that.
3354
4624
  react_jsx_runtime.jsx("button", {
3355
4625
  onClick: toggleFuzzy,
3356
- title: fuzzy ? T("search.fuzzy.tipOn") : T("search.fuzzy.tipOff"),
4626
+ disabled: searchScope === "cross",
4627
+ title: searchScope === "cross" ? T("search.cross.note") : (fuzzy ? T("search.fuzzy.tipOn") : T("search.fuzzy.tipOff")),
3357
4628
  "data-fuzzy": fuzzy ? "on" : "off",
3358
4629
  style: {
3359
4630
  flex: "none",
3360
- width: 34,
3361
- height: 34,
4631
+ width: sheetOpen ? 40 : 34,
4632
+ height: sheetOpen ? 40 : 34,
3362
4633
  display: "flex",
3363
4634
  alignItems: "center",
3364
4635
  justifyContent: "center",
3365
4636
  borderRadius: "50%",
3366
4637
  cornerShape: "round",
3367
- fontSize: 11,
4638
+ fontSize: sheetOpen ? 13 : 11,
3368
4639
  color: fuzzy ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
3369
4640
  background: fuzzy ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
3370
4641
  border: "none",
3371
- cursor: "pointer",
4642
+ cursor: searchScope === "cross" ? "default" : "pointer",
4643
+ opacity: searchScope === "cross" ? 0.45 : 1,
3372
4644
  whiteSpace: "nowrap",
3373
- transition: "background 0.25s ease, color 0.25s ease"
4645
+ transition: "background 0.25s ease, color 0.25s ease, opacity 0.25s ease"
3374
4646
  },
3375
4647
  children: T("search.fuzzy")
3376
4648
  })
3377
- ]
3378
- })
3379
- })
3380
- }) : null,
3381
- // outline list (paged: newest first, scroll to the top loads older);
3382
- // the scrollbar follows the dock side (rtl flips it to the left)
3383
- react_jsx_runtime.jsx("div", {
3384
- ref: listRef,
3385
- className: "dqt-list",
3386
- style: {
3387
- overflowY: "auto",
3388
- // NO top padding: a sticky child is confined to its containing
3389
- // block, so a padded scroll container would hold the pinned group
3390
- // header that many pixels below the toolbar (a visible gap). Spacing
3391
- // between groups comes from the per-group divider instead.
3392
- padding: "0 8px 6px",
3393
- flex: "1 1 auto",
3394
- minHeight: 0,
3395
- direction: dockRight ? "ltr" : "rtl"
3396
- },
3397
- onScroll: onListScroll,
3398
- onWheel: onListWheel,
3399
- children: react_jsx_runtime.jsx("div", {
3400
- style: { direction: "ltr" },
3401
- children: query.trim()
3402
- ? [
3403
- react_jsx_runtime.jsx("div", {
3404
- key: "results",
3405
- children: renderResults(resultRows, query.trim(), C, goToRow, activeResultRow, renderExtra)
3406
- }),
3407
- // older history is one upward scroll away — say so until the
3408
- // reader actually scrolls up (then the hint has done its job)
3409
- hint ? react_jsx_runtime.jsx("div", {
3410
- key: "hint",
3411
- style: {
3412
- display: "flex",
3413
- alignItems: "center",
3414
- justifyContent: "center",
3415
- padding: "10px 6px 4px",
3416
- fontSize: 11,
3417
- color: C.muted,
3418
- opacity: 0.85,
3419
- textAlign: "center"
3420
- },
3421
- children: hint === "oldest" ? T("search.hintOldest") : T("search.hintMore")
3422
- }) : null
3423
- ]
3424
- : renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup, renderExtra)
4649
+ ]
4650
+ })
4651
+ })
4652
+ }) : null;
4653
+
4654
+ // ---- list content: the outline, or the search results ---------------------
4655
+ // Built once and placed either inside the single docked list, or split across
4656
+ // the curtain's two columns (outline left, results right).
4657
+ var resultsEl = query.trim()
4658
+ ? (searchScope === "cross"
4659
+ ? react_jsx_runtime.jsx("div", {
4660
+ key: "cross",
4661
+ children: renderCrossResults(cross, query.trim(), C, crossTitle, openCrossHit)
4662
+ })
4663
+ : react_jsx_runtime.jsx("div", {
4664
+ key: "results",
4665
+ children: renderResults(resultRows, query.trim(), C, goToRow, activeResultRow, renderExtra)
4666
+ }))
4667
+ : null;
4668
+ // older history is one upward scroll away — say so until the reader actually
4669
+ // scrolls up (then the hint has done its job)
4670
+ var hintEl = hint ? react_jsx_runtime.jsx("div", {
4671
+ key: "hint",
4672
+ style: {
4673
+ display: "flex",
4674
+ alignItems: "center",
4675
+ justifyContent: "center",
4676
+ padding: "10px 6px 4px",
4677
+ fontSize: 11,
4678
+ color: C.muted,
4679
+ opacity: 0.85,
4680
+ textAlign: "center"
4681
+ },
4682
+ children: hint === "oldest" ? T("search.hintOldest") : T("search.hintMore")
4683
+ }) : null;
4684
+ var outlineEl = renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup,
4685
+ questionsOnly ? Object.assign({}, renderExtra, { questionsOnly: true }) : renderExtra);
4686
+ // The curtain keeps the outline in the middle of the surface (its own column,
4687
+ // centred with room to spare at both sides) and pushes the search in from the
4688
+ // right; the docked panel keeps its original "results replace the list".
4689
+ var listChildren = sheetOpen
4690
+ ? [outlineEl, hintEl]
4691
+ : (resultsEl ? [resultsEl, hintEl] : outlineEl);
4692
+ var listEl = react_jsx_runtime.jsx("div", {
4693
+ ref: listRef,
4694
+ className: "dqt-list",
4695
+ // focusable so the arrow keys can drive the cursor; the panel root handles
4696
+ // the same keys, so either focus target works
4697
+ tabIndex: 0,
4698
+ role: "group",
4699
+ "aria-label": T("a11y.list"),
4700
+ style: {
4701
+ overflowY: "auto",
4702
+ // NO top padding: a sticky child is confined to its containing
4703
+ // block, so a padded scroll container would hold the pinned group
4704
+ // header that many pixels below the toolbar (a visible gap). Spacing
4705
+ // between groups comes from the per-group divider instead.
4706
+ padding: sheetOpen ? "0 20px 8px" : "0 8px 6px",
4707
+ flex: "1 1 auto",
4708
+ minHeight: 0,
4709
+ // across the curtain the list does not touch the edges: it is a centred
4710
+ // column with room at both sides, which is also what makes it slide left
4711
+ // when the search column is pushed in
4712
+ width: sheetOpen ? "100%" : undefined,
4713
+ maxWidth: sheetOpen ? "90%" : undefined,
4714
+ margin: sheetOpen ? "0 auto" : undefined,
4715
+ // the 20px side padding sits INSIDE the 90% (content-box would make the
4716
+ // rendered column 90% + 40px, i.e. ~93% of the curtain)
4717
+ boxSizing: sheetOpen ? "border-box" : undefined,
4718
+ // the scrollbar follows the dock side in the narrow panel; across the
4719
+ // curtain it sits on the list's right edge
4720
+ direction: (sheetOpen || dockRight) ? "ltr" : "rtl"
4721
+ },
4722
+ onScroll: onListScroll,
4723
+ onWheel: onListWheel,
4724
+ children: react_jsx_runtime.jsx("div", {
4725
+ // the two-phase cross-fade of the questions-only switch rides on this
4726
+ // wrapper (classes, never inline animation: React rewrites inline styles
4727
+ // every render and would restart or drop the animation)
4728
+ className: "dqt-list-body"
4729
+ + (listFade === "out" ? " dqt-mode-out" : (listFade === "in" ? " dqt-mode-in" : "")),
4730
+ style: { direction: "ltr" },
4731
+ children: listChildren
4732
+ })
4733
+ });
4734
+ // ---- curtain search column: pushed in from the right edge -----------------
4735
+ // The panel's own search box and its two buttons, then the hits below them.
4736
+ // Only the column's WIDTH animates (0 <-> 360) so the outline is pushed left
4737
+ // instead of jumped; an inner box keeps the full width so the content never
4738
+ // squashes while the column narrows, and the column is always mounted while the
4739
+ // curtain is up so both directions animate.
4740
+ var sheetColW = (searchOpen || searchAnim === "out") ? SHEET_SEARCH_W : 0;
4741
+ var sheetSearchColEl = sheetOpen ? react_jsx_runtime.jsx("div", {
4742
+ className: "dqt-sheet-search",
4743
+ "data-sheet-search": sheetColW > 0 ? "on" : undefined,
4744
+ style: {
4745
+ flex: "none",
4746
+ width: sheetColW,
4747
+ overflow: "hidden",
4748
+ minHeight: 0,
4749
+ borderLeft: sheetColW > 0 ? "1px solid " + C.panelBorder : "none",
4750
+ transition: "width 0.24s " + EASE + ", border-color 0.24s " + EASE
4751
+ },
4752
+ children: react_jsx_runtime.jsx("div", {
4753
+ style: { width: SHEET_SEARCH_W, height: "100%", display: "flex", flexDirection: "column", minHeight: 0 },
4754
+ children: [
4755
+ searchRowEl,
4756
+ // empty until something is typed — no placeholder note in the way
4757
+ react_jsx_runtime.jsx("div", {
4758
+ className: "dqt-sheet-results",
4759
+ style: { flex: "1 1 auto", minHeight: 0, overflowY: "auto", padding: "0 8px 8px", direction: "ltr" },
4760
+ children: resultsEl
3425
4761
  })
3426
- }),
4762
+ ]
4763
+ })
4764
+ }) : null;
4765
+ // the outline and the search column share one row in BOTH layouts, so the list
4766
+ // is never moved between parents when the curtain opens or closes
4767
+ var bodyRowEl = react_jsx_runtime.jsx("div", {
4768
+ className: "dqt-body-row",
4769
+ style: { flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "row", alignItems: "stretch" },
4770
+ children: [listEl, sheetSearchColEl]
4771
+ });
4772
+ var panelEl = react_jsx_runtime.jsx("div", {
4773
+ ref: panelRootRef,
4774
+ // the panel takes focus on any click inside it (except into a text field), so
4775
+ // ↑/↓/Enter/Esc keep working right after a mouse interaction instead of only
4776
+ // when the user tabs into the widget first
4777
+ tabIndex: -1,
4778
+ onKeyDown: onPanelKeyDown,
4779
+ onMouseDown: function (e) {
4780
+ var t = e.target;
4781
+ var tag = t && t.tagName ? String(t.tagName).toUpperCase() : "";
4782
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (t && t.isContentEditable)) return;
4783
+ var root = panelRootRef.current;
4784
+ if (root && typeof root.focus === "function") root.focus();
4785
+ },
4786
+ style: sheetOpen ? {
4787
+ // inside the sheet the panel IS the content: the wrapper owns position,
4788
+ // size and the drop-in animation, and the content renders at 100% (the
4789
+ // panel-scale preference is a docked-panel setting, not a sheet one)
4790
+ position: "relative",
4791
+ width: "100%",
4792
+ flex: "1 1 auto",
4793
+ minHeight: 0,
4794
+ display: "flex",
4795
+ flexDirection: "column",
4796
+ background: C.panelBg,
4797
+ border: "none",
4798
+ borderRadius: 0,
4799
+ zIndex: Z_PANEL,
4800
+ overflow: "hidden",
4801
+ color: C.text,
4802
+ opacity: 1,
4803
+ pointerEvents: chatViewActive ? "auto" : "none"
4804
+ } : {
4805
+ position: "fixed",
4806
+ top: zLen(baseTopPx),
4807
+ left: zLen(panelLeft),
4808
+ width: zLen(panelW),
4809
+ maxHeight: zLen(vMaxH),
4810
+ height: panelH > 0 ? zLen(panelH) : undefined,
4811
+ zoom: zoom === 1 ? undefined : zoom,
4812
+ display: "flex",
4813
+ flexDirection: "column",
4814
+ background: C.panelBg,
4815
+ border: "none",
4816
+ borderRadius: 12,
4817
+ zIndex: Z_PANEL,
4818
+ overflow: "hidden",
4819
+ color: C.text,
4820
+ opacity: panelOpacity,
4821
+ clipPath: panelClip,
4822
+ // inner shadow only (no outer): theme-aware (white bevel in dark mode,
4823
+ // soft shade in light) — outer shadow would be clipped by the collapse
4824
+ // clip-path and the panel edges.
4825
+ boxShadow: innerShadow(),
4826
+ // A view switch swaps in a short, delay-free opacity fade; the dock and
4827
+ // collapse animations keep their own (slower) timings.
4828
+ transition: viewFading ? "opacity 0.3s ease" : panelTransition,
4829
+ // held back while the edge handle slips home (see edgeLeaving): the panel is
4830
+ // already at its open geometry here, only the animation waits
4831
+ transitionDelay: edgeLeaving ? EDGE_OUT_MS + "ms" : undefined,
4832
+ pointerEvents: chatViewActive ? "auto" : "none"
4833
+ },
4834
+ onMouseEnter: function () { setHovered(true); },
4835
+ onMouseLeave: function () { setHovered(false); },
4836
+ onClickCapture: function (e) {
4837
+ // jump handled in the capture phase (bubble-phase handlers are unreliable here)
4838
+ var t = e.target;
4839
+ // the "to the section's end" control sits INSIDE a jumpable row, and the
4840
+ // row's own jump would otherwise fire first (capture runs top-down), so
4841
+ // this branch is checked first and takes the event entirely
4842
+ var endBtn = (t && t.closest) ? t.closest("[data-dqt-end]") : null;
4843
+ if (endBtn && endBtn.dataset) {
4844
+ e.preventDefault();
4845
+ e.stopPropagation();
4846
+ if (endBtn.dataset.endTurn !== undefined) {
4847
+ endJumpTurn(endBtn.dataset.endKey);
4848
+ } else {
4849
+ endJumpRow(endBtn.dataset.endKey, endBtn.dataset.endIdx !== undefined ? Number(endBtn.dataset.endIdx) : 0);
4850
+ }
4851
+ // same hand-back rule as picking a row: the curtain closes
4852
+ if (sheetOpen) closeSheet();
4853
+ return;
4854
+ }
4855
+ var item = (t && t.closest) ? t.closest("[data-jump-key]") : null;
4856
+ if (item && item.dataset && item.dataset.jumpKey) {
4857
+ e.preventDefault();
4858
+ e.stopPropagation();
4859
+ var idx = item.dataset.jumpIdx !== undefined ? Number(item.dataset.jumpIdx) : 0;
4860
+ jump(item.dataset.jumpKey, idx);
4861
+ // a curtain hands you back to the conversation once you picked a row
4862
+ if (sheetOpen) closeSheet();
4863
+ return;
4864
+ }
4865
+ // ANY other row (group header, in-session result, cross-session hit)
4866
+ // carries its own click handler and keeps it — no stopPropagation here —
4867
+ // but picking a row still hands the reader back to the conversation
4868
+ if (sheetOpen && t && t.closest && t.closest("[data-nav-row]")) closeSheet();
4869
+ },
4870
+ children: [
4871
+ sheetOpen ? sheetChromeEl : dockHeaderEl,
4872
+ // the docked panel keeps its search band under the header; across the
4873
+ // curtain the same row lives at the top of the pushed-in search column
4874
+ sheetOpen ? null : searchRowEl,
4875
+ bodyRowEl,
3427
4876
  // "back to the newest row": floats over the list's lower-right corner and
3428
4877
  // fades in the moment the newest turns fall below the fold. Kept MOUNTED
3429
4878
  // and toggled by opacity/pointer-events — an unmounted button has nothing
@@ -3446,8 +4895,9 @@ window.__ModuleLoader__.load({
3446
4895
  },
3447
4896
  style: {
3448
4897
  position: "absolute",
3449
- // clear of the resize handle in the very corner (16x16)
3450
- right: 18,
4898
+ // clear of the resize handle in the very corner (16x16); across the
4899
+ // curtain it also clears the pushed-in search column
4900
+ right: (sheetOpen && sheetColW > 0 ? SHEET_SEARCH_W : 0) + 18,
3451
4901
  bottom: 12,
3452
4902
  width: 26,
3453
4903
  height: 26,
@@ -3490,10 +4940,19 @@ window.__ModuleLoader__.load({
3490
4940
  })
3491
4941
  }),
3492
4942
  toastEl,
4943
+ // Polite live region: match stepping, result counts and cursor moves land
4944
+ // here. Visually hidden (`.dqt-sr`) — it exists for assistive tech only.
4945
+ react_jsx_runtime.jsx("span", {
4946
+ className: "dqt-sr",
4947
+ role: "status",
4948
+ "aria-live": "polite",
4949
+ "aria-atomic": "true",
4950
+ children: liveText
4951
+ }),
3493
4952
  // resize handles (right edge: width, bottom edge: height)
3494
4953
  // Their grab strips keep their SCREEN thickness under the scale factor:
3495
4954
  // they are part of the panel's box, not of its magnified content.
3496
- react_jsx_runtime.jsx("div", {
4955
+ sheetOpen ? null : react_jsx_runtime.jsx("div", {
3497
4956
  style: {
3498
4957
  position: "absolute",
3499
4958
  top: 0,
@@ -3507,7 +4966,7 @@ window.__ModuleLoader__.load({
3507
4966
  onPointerDown: onResizeWDown,
3508
4967
  title: T("resize.w")
3509
4968
  }),
3510
- react_jsx_runtime.jsx("div", {
4969
+ sheetOpen ? null : react_jsx_runtime.jsx("div", {
3511
4970
  style: {
3512
4971
  position: "absolute",
3513
4972
  left: 0,
@@ -3521,8 +4980,8 @@ window.__ModuleLoader__.load({
3521
4980
  onPointerDown: onResizeHDown,
3522
4981
  title: T("resize.h")
3523
4982
  }),
3524
- // corner handle: resize width AND height at once
3525
- react_jsx_runtime.jsx("div", {
4983
+ // corner handle: resize width AND height at once (dock-only too)
4984
+ sheetOpen ? null : react_jsx_runtime.jsx("div", {
3526
4985
  style: {
3527
4986
  position: "absolute",
3528
4987
  right: 0,
@@ -3536,88 +4995,54 @@ window.__ModuleLoader__.load({
3536
4995
  onPointerDown: onResizeCornerDown,
3537
4996
  title: T("resize.wh")
3538
4997
  }),
3539
- // heading-level picker: pops down from the round header button and its
3540
- // LEFT edge lines up with that button (the button now sits at the left end
3541
- // of the header, so 10px is exactly the header's own left padding).
3542
- // Open/close animate from the button's center: the animation lives in
3543
- // the injected stylesheet (classes, not inline animation) so React
3544
- // re-renders never restart or cancel it — an inline `animation` set on
3545
- // every render made the entrance invisible and the exit never play.
3546
- (levelsOpen || levelsClosing) ? react_jsx_runtime.jsx("div", {
3547
- className: "dqt-levels-pop" + (levelsClosing ? " dqt-levels-pop-closing" : ""),
3548
- style: {
3549
- position: "absolute",
3550
- top: 40,
3551
- left: 10,
3552
- zIndex: 30,
3553
- display: "flex",
3554
- alignItems: "center",
3555
- flexWrap: "wrap",
3556
- gap: 4,
3557
- padding: 8,
3558
- background: C.panelBg,
3559
- border: "1px solid " + C.panelBorder,
3560
- borderRadius: 10,
3561
- cornerShape: "round",
3562
- boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
3563
- maxWidth: "calc(100% - 20px)",
3564
- // the button's center sits 12px inside the popup's left edge
3565
- transformOrigin: "12px top"
3566
- },
3567
- children: [1, 2, 3, 4, 5, 6].map(function (lv) {
3568
- var active = !!levelSet[lv];
3569
- return react_jsx_runtime.jsx("button", {
3570
- onClick: function () { toggleLevel(lv); },
3571
- title: active ? T("levels.hide") + lv : T("levels.show") + lv,
3572
- style: {
3573
- height: 22,
3574
- padding: "0 7px",
3575
- border: "none",
3576
- borderRadius: 6,
3577
- cornerShape: "round",
3578
- cursor: "pointer",
3579
- fontSize: 11,
3580
- lineHeight: "22px",
3581
- fontFamily: "inherit",
3582
- background: active
3583
- ? C.chip
3584
- : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
3585
- color: active ? C.accent : C.muted,
3586
- fontWeight: active ? 600 : 400
3587
- },
3588
- children: "H" + lv
3589
- }, "lv" + lv);
3590
- })
3591
- }) : null
4998
+ levelsPopDockEl
3592
4999
  ]
3593
5000
  });
3594
5001
 
3595
- // ---- edge collapse handle: fades+pops in after the panel fully slides away ----
3596
- var edgeEl = !open ? react_jsx_runtime.jsx("div", {
5002
+ // ---- edge collapse handle ------------------------------------------------
5003
+ // It emerges from BEHIND the conversation area's edge and slips back into it, the
5004
+ // same vocabulary the docked panel uses when it collapses: the box is parked
5005
+ // EDGE_W outside the line and clipped at it, so the 12px pill is progressively
5006
+ // revealed as it slides out. A plain fade-in-place made it read as appearing and
5007
+ // vanishing ON the divider line. `open` also drives it (fade + slide home) and
5008
+ // `edgeGone` keeps the element in the tree until that motion has played.
5009
+ var EDGE_W = 26;
5010
+ var edgeShown = !open && handleShown && chatViewActive;
5011
+ var edgeLineLeft = viewport ? viewport.left : 0;
5012
+ var edgeLineRight = viewport ? viewport.right + 52 : 60; // clear of the milestone rail
5013
+ var edgeEl = (!open || !edgeGone) ? react_jsx_runtime.jsx("div", {
3597
5014
  style: {
3598
5015
  position: "fixed",
3599
- width: 26,
5016
+ width: EDGE_W,
3600
5017
  height: 92,
3601
5018
  display: "flex",
3602
5019
  alignItems: "center",
3603
5020
  justifyContent: "center",
3604
5021
  cursor: "pointer",
3605
- zIndex: Z_BASE,
5022
+ zIndex: Z_HANDLE,
3606
5023
  // the ratio counts from the BOTTOM (0% = bottom, 100% = top) — at 0.5 this
3607
- // is the same centred spot the handle always had
3608
- top: (viewport
3609
- ? viewport.top + (viewport.height - 92) * handleFromTop
3610
- : (window.innerHeight - 92) * handleFromTop) + "px",
3611
- transform: handleShown ? "translateX(0)" : (dockRight ? "translateX(16px)" : "translateX(-16px)"),
3612
- opacity: chatViewActive ? 1 : 0,
3613
- pointerEvents: chatViewActive ? "auto" : "none",
3614
- transition: "transform 0.4s " + EASE + ", opacity 0.26s ease",
5024
+ // is the same centred spot the handle always had. The 100% end still lands
5025
+ // exactly on the area's top edge; only the far end is floored, because the
5026
+ // handle is the ONLY way back to a collapsed panel and may never be pushed
5027
+ // off the bottom by a stored rect that is one layout generation behind.
5028
+ top: Math.max(0, Math.min(Math.max(0, window.innerHeight - 100),
5029
+ (viewport
5030
+ ? viewport.top + (viewport.height - 92) * handleFromTop
5031
+ : (window.innerHeight - 92) * handleFromTop))) + "px",
5032
+ // clipped at the line it hides behind: parked EDGE_W further out, the whole
5033
+ // box falls inside the clipped strip, so it is invisible without a fade
5034
+ clipPath: dockRight
5035
+ ? (edgeShown ? "inset(0 0 0 0)" : "inset(0 " + EDGE_W + "px 0 0)")
5036
+ : (edgeShown ? "inset(0 0 0 0)" : "inset(0 0 0 " + EDGE_W + "px)"),
5037
+ opacity: (chatViewActive && !open) ? 1 : 0,
5038
+ pointerEvents: edgeShown ? "auto" : "none",
5039
+ transition: "left 0.22s " + EASE + ", right 0.22s " + EASE + ", clip-path 0.22s " + EASE + ", opacity 0.18s ease",
3615
5040
  ...(dockRight
3616
- ? { right: viewport ? viewport.right + 52 : 60 } // clear of the milestone rail
3617
- : { left: viewport ? viewport.left : 0 })
5041
+ ? { right: edgeShown ? edgeLineRight : edgeLineRight - EDGE_W }
5042
+ : { left: edgeShown ? edgeLineLeft : edgeLineLeft - EDGE_W })
3618
5043
  },
3619
5044
  title: T("handle.expand"),
3620
- onClick: function () { setOpen(true); },
5045
+ onClick: function () { openFromEdge(); },
3621
5046
  onMouseEnter: function () { if (edgeRef.current) edgeRef.current.style.opacity = "1"; },
3622
5047
  onMouseLeave: function () { if (edgeRef.current) edgeRef.current.style.opacity = "0.5"; },
3623
5048
  children: react_jsx_runtime.jsx("div", {
@@ -3651,6 +5076,111 @@ window.__ModuleLoader__.load({
3651
5076
  })
3652
5077
  }) : null;
3653
5078
 
5079
+ // where the curtain hangs from: just under the view tabs, with a floor so the
5080
+ // tabs can never eat the whole surface
5081
+ var convTopPx = viewport ? viewport.top : 0;
5082
+ var sheetTopPx = viewport
5083
+ ? Math.max(convTopPx, Math.min(sheetStrip ? sheetStrip.bottom : convTopPx, convTopPx + viewport.height - 160))
5084
+ : 0;
5085
+ // The handle sits INSIDE the tab strip, bottom edge on that same line, so no
5086
+ // conversation text can ever run under it. The strip measured 25px tall on this
5087
+ // build, so the hit box fits the band instead of the other way round (a fixed
5088
+ // 26px box poked 3px below the line, straight back into the text it was moved
5089
+ // away from); the 12px pill inside is bottom-aligned, so the visible control
5090
+ // rests ON the line. Falls back to hanging below the line if the strip was never
5091
+ // measured.
5092
+ var HANDLE_BOX_MAX = 26;
5093
+ var stripH = sheetStrip ? Math.max(0, sheetStrip.bottom - sheetStrip.top) : 0;
5094
+ var handleH = sheetStrip ? Math.max(16, Math.min(HANDLE_BOX_MAX, stripH)) : HANDLE_BOX_MAX;
5095
+ var handleTopPx = sheetStrip ? sheetStrip.bottom - handleH : sheetTopPx;
5096
+
5097
+ // ---- curtain handle resting on the view tab strip's line -----------------
5098
+ // A droplet held to the line by surface tension rather than a floating pill: the
5099
+ // silhouette flares out into two concave fillets that meet the strip's lower line
5100
+ // tangentially, and the little triangle says "this drops down". Laid out so its
5101
+ // base sits exactly ON that line (the svg's own bottom edge), centred on the
5102
+ // conversation area — the band above the line is host chrome and never carries
5103
+ // transcript text, which is what made the old handle unreadable.
5104
+ // 140 wide and only 13.5 tall: the reader asked for a wider, flatter droplet
5105
+ // (it started at 92, then 120).
5106
+ var DROP_W = 140;
5107
+ var topHandleEl = (!sheetOpen || !sheetHandleGone) && chatViewActive ? react_jsx_runtime.jsx("div", {
5108
+ className: "dqt-sheet-handle",
5109
+ "data-sheet-handle": "on",
5110
+ style: {
5111
+ position: "fixed",
5112
+ width: DROP_W,
5113
+ height: handleH,
5114
+ display: "flex",
5115
+ alignItems: "flex-end",
5116
+ justifyContent: "center",
5117
+ boxSizing: "border-box",
5118
+ cursor: "pointer",
5119
+ zIndex: Z_HANDLE,
5120
+ top: handleTopPx,
5121
+ transformOrigin: "50% 100%",
5122
+ // The press is SLOW FIRST, THEN FAST, and it is the pop keyframe below read
5123
+ // backwards: `cubic-bezier(0.75, 0, 1, 0.45)` mirrors exactly onto the pop's
5124
+ // `cubic-bezier(0, 0.55, 0.25, 1)`, so the two directions are one motion and
5125
+ // its rewind rather than two lookalike eases. Over the 200ms: 86% of the clock
5126
+ // buys the first 40% of the squash, and the last 40% lands inside ~11ms. The
5127
+ // fade is held back past the press so the squash is never hidden behind it.
5128
+ opacity: sheetOpen ? 0 : 1,
5129
+ transition: "transform 0.2s cubic-bezier(0.75, 0, 1, 0.45), opacity 0.12s ease 0.2s",
5130
+ // centred on the conversation area, and clamped like the side handle: it is
5131
+ // the only entry point to the curtain, so it stays inside the window even
5132
+ // if the stored area rect is one layout generation behind
5133
+ left: Math.max(8, Math.min(Math.max(8, window.innerWidth - DROP_W - 8),
5134
+ viewport
5135
+ ? viewport.left + (Math.max(320, window.innerWidth - viewport.left - viewport.right) - DROP_W) / 2
5136
+ : (window.innerWidth - DROP_W) / 2)) + "px"
5137
+ },
5138
+ title: T("sheet.openHandle"),
5139
+ onClick: function () {
5140
+ var el = sheetHandleRef.current;
5141
+ if (el && el.parentElement && el.parentElement.style) el.parentElement.style.transform = "scaleY(0.12)";
5142
+ setSheetHandleGone(false);
5143
+ // The press has to be SEEN before the curtain takes the stage. With the panel
5144
+ // open it already is: the panel's own 300ms tuck comes first. Collapsed, the
5145
+ // curtain used to drop on this very frame and cover the droplet (z 16 over
5146
+ // 12), which is why the press "had no animation" — so here the curtain waits
5147
+ // for the press to play out.
5148
+ if (open) openSheet();
5149
+ else {
5150
+ if (dropOpenRef.current) clearTimeout(dropOpenRef.current);
5151
+ dropOpenRef.current = setTimeout(function () {
5152
+ dropOpenRef.current = null;
5153
+ openSheet();
5154
+ }, DROP_OUT_MS);
5155
+ }
5156
+ },
5157
+ onMouseEnter: function () { if (sheetHandleRef.current) sheetHandleRef.current.style.opacity = "1"; },
5158
+ onMouseLeave: function () { if (sheetHandleRef.current) sheetHandleRef.current.style.opacity = "0.5"; },
5159
+ children: react_jsx_runtime.jsx("svg", {
5160
+ ref: sheetHandleRef,
5161
+ width: DROP_W,
5162
+ height: 17,
5163
+ viewBox: "0 0 140 17",
5164
+ // flush with the box's bottom edge, i.e. exactly on the strip's line
5165
+ style: { display: "block", opacity: 0.5, transition: "opacity 0.25s ease", cursor: "pointer" },
5166
+ children: [
5167
+ react_jsx_runtime.jsx("path", {
5168
+ // base from x=8 to x=132 (124px), dome up at y=3.5, both sides flaring
5169
+ // into concave fillets that become horizontal where they touch the line
5170
+ d: "M8 17 Q30 17 42 9 C50 4.5 58 3.5 70 3.5 C82 3.5 90 4.5 98 9 Q110 17 132 17 Z",
5171
+ fill: "var(--dsw-alias-border-l2, rgba(160,160,160,0.55))"
5172
+ }, "drop"),
5173
+ react_jsx_runtime.jsx("path", {
5174
+ // the side handle's own triangle, turned a quarter turn and taken down a
5175
+ // shade — 14 x 4.5 against its 5.5 x 16 — so it reads as the same
5176
+ // affordance without crowding the smaller droplet it sits in
5177
+ d: "M63 8.5 L77 8.5 L70 13 Z",
5178
+ fill: "var(--dsw-alias-label-secondary, #9aa0ab)"
5179
+ }, "tip")
5180
+ ]
5181
+ })
5182
+ }) : null;
5183
+
3654
5184
  // ---- hover preview card ------------------------------------------------
3655
5185
  // Rendered INSIDE the body portal (position: fixed) so the outline's own
3656
5186
  // scroll container can never clip it, and pointer-events: none so it can
@@ -3699,22 +5229,179 @@ window.__ModuleLoader__.load({
3699
5229
  color: C.text,
3700
5230
  border: "1px solid " + C.panelBorder,
3701
5231
  boxShadow: "0 8px 24px rgba(0,0,0,0.28)",
3702
- zIndex: Z_BASE + 1,
5232
+ // above the panel and the curtain — it is a sibling of both in this
5233
+ // portal, and both sit below it in the layer band we claim
5234
+ zIndex: Z_HOVER,
3703
5235
  pointerEvents: "none"
3704
5236
  },
3705
5237
  children: cardChildren
3706
5238
  }, "hover-card");
3707
5239
  }
3708
5240
 
5241
+ // ---- the curtain shell ---------------------------------------------------
5242
+ // TWO boxes: an outer clip box that sits at the anchor line with the curtain's
5243
+ // own size, and the curtain inside it, which flies down from above that line
5244
+ // (translateY -100% -> 0) and back up on the way out. The clip is what makes it
5245
+ // emerge from under the view tabs — like the docked panel emerging from behind
5246
+ // the sidebar — so the surface is never seen above the line it hangs from.
5247
+ // The outer box never unmounts: while the curtain is down it is a zero-size,
5248
+ // non-clipping pass-through, so the panel (and the whole list DOM) is never
5249
+ // moved or rebuilt when the curtain opens or closes.
5250
+ var sheetUp = sheetOpen && chatViewActive;
5251
+ var sheetShellEl = react_jsx_runtime.jsx("div", {
5252
+ key: "sheet-shell",
5253
+ className: "dqt-sheet-clip",
5254
+ "data-sheet-shell": sheetUp ? "on" : undefined,
5255
+ style: sheetUp ? {
5256
+ position: "fixed",
5257
+ // hangs from just below the 对话/轨迹/上下文 strip
5258
+ top: sheetTopPx,
5259
+ left: viewport ? viewport.left : 0,
5260
+ width: viewport ? Math.max(320, window.innerWidth - viewport.left - viewport.right) : "100%",
5261
+ // a curtain over the conversation, not the whole screen: the composer and
5262
+ // the newest message stay reachable below it. The height is measured from
5263
+ // the conversation box (not from the curtain's own top) so hanging it below
5264
+ // the tabs does not push its bottom edge down over the composer.
5265
+ height: viewport
5266
+ ? Math.max(240, Math.min(760, (viewport.height - Math.max(0, sheetTopPx - viewport.top)) * 0.82))
5267
+ : "70vh",
5268
+ overflow: "hidden",
5269
+ zIndex: Z_SHEET
5270
+ } : {
5271
+ // Nothing while it is down — and in particular no border: a 1px border on a
5272
+ // zero-size box is still a 2px border box, and with the wrapper's overflow
5273
+ // visible that added 2px to the DOCUMENT's scrollable overflow. That raised a
5274
+ // page scrollbar while the curtain was down, so opening it dropped the
5275
+ // scrollbar and widened the whole app by ~8px — which is exactly the small
5276
+ // rightward shift of the conversation the reader saw. A fixed, zero-size,
5277
+ // borderless box contributes nothing, and the fixed-positioned docked panel
5278
+ // inside still resolves against the viewport and is not clipped by it.
5279
+ //
5280
+ // It DOES have to carry the panel's own layer though: a fixed-position
5281
+ // element creates a stacking context even at `z-index: auto`, so with the
5282
+ // shell at auto the docked panel inside it could never paint at its own
5283
+ // z-index — it sat below every positive-z-index element of the app. The
5284
+ // transcript width grips (z 8, a 40px full-height column just outside the
5285
+ // text column) won the hit test INSIDE the open panel: the cursor turned
5286
+ // into col-resize over the outline and a press there resized the
5287
+ // conversation instead of using the panel. Probed in a real browser with
5288
+ // elementFromPoint plus a temporary marker of the same z-index:
5289
+ // shell auto -> at the grip's own column the hit was the grip
5290
+ // shell 14 -> the hit is the outline row, and a 13 marker no longer
5291
+ // covers the panel while a 15 marker still does
5292
+ zIndex: Z_PANEL,
5293
+ position: "fixed",
5294
+ top: 0,
5295
+ left: 0,
5296
+ width: 0,
5297
+ height: 0,
5298
+ overflow: "visible"
5299
+ },
5300
+ children: react_jsx_runtime.jsx("div", {
5301
+ ref: sheetRef,
5302
+ className: sheetUp ? "dqt-sheet dqt-sheet-open" : "dqt-sheet-idle",
5303
+ "data-sheet": sheetUp ? "on" : undefined,
5304
+ style: {
5305
+ width: sheetUp ? "100%" : 0,
5306
+ height: sheetUp ? "100%" : 0,
5307
+ display: "flex",
5308
+ flexDirection: "column",
5309
+ background: C.panelBg,
5310
+ color: C.text,
5311
+ border: sheetUp ? "1px solid " + C.panelBorder : "none",
5312
+ // square where it meets the top edge, since the curtain hangs from there;
5313
+ // no dimming layer and no drop shadow either — the conversation below
5314
+ // stays legible and usable, and the curtain announces itself by flying
5315
+ borderRadius: "0 0 12px 12px",
5316
+ overflow: "hidden"
5317
+ },
5318
+ children: panelEl
5319
+ })
5320
+ });
5321
+
3709
5322
  return react_dom.createPortal(
3710
- react_jsx_runtime.jsx(ErrorBoundary, { children: [panelEl, edgeEl, hoverEl] }),
5323
+ react_jsx_runtime.jsx(ErrorBoundary, {
5324
+ children: [sheetShellEl, edgeEl, topHandleEl, hoverEl]
5325
+ }),
3711
5326
  document.body
3712
5327
  );
3713
5328
  }
3714
5329
 
5330
+ // the "to the section's end" control that every jumpable row (outline entries
5331
+ // and group headers, docked and curtain alike) carries at its far right. It
5332
+ // stays hidden until the row is hovered — the row's own mouseenter/leave
5333
+ // reveal it — and the click is handled once, in the panel's capture phase,
5334
+ // so the row's jump-to-start can never fire alongside it.
5335
+ function renderEndBtn(C, key, idx, turn) {
5336
+ // These markers are not decoration: the row's hover handler looks for
5337
+ // [data-dqt-end] to reveal the control, and the panel's capture phase looks
5338
+ // for it to route the click to the end jump instead of the row's own
5339
+ // jump-to-start. They are deliberately NOT data-jump-key: that attribute
5340
+ // belongs to the rows themselves, and two kinds of element claiming it made
5341
+ // every "find the row" query ambiguous.
5342
+ var attrs = { "data-dqt-end": "1" };
5343
+ if (key) attrs["data-end-key"] = key;
5344
+ if (idx !== undefined && idx !== null) attrs["data-end-idx"] = String(idx);
5345
+ if (turn !== undefined && turn !== null) attrs["data-end-turn"] = String(turn);
5346
+ return react_jsx_runtime.jsx("button", Object.assign({
5347
+ type: "button",
5348
+ tabIndex: -1,
5349
+ title: T("row.end"),
5350
+ onMouseEnter: function (e) {
5351
+ e.currentTarget.style.background = C.hover;
5352
+ e.currentTarget.style.color = C.text;
5353
+ },
5354
+ onMouseLeave: function (e) {
5355
+ e.currentTarget.style.background = "transparent";
5356
+ e.currentTarget.style.color = C.muted;
5357
+ },
5358
+ style: {
5359
+ position: "absolute",
5360
+ right: 4,
5361
+ top: "50%",
5362
+ transform: "translateY(-50%)",
5363
+ width: 20,
5364
+ height: 20,
5365
+ display: "flex",
5366
+ alignItems: "center",
5367
+ justifyContent: "center",
5368
+ padding: 0,
5369
+ border: "none",
5370
+ borderRadius: 999,
5371
+ cornerShape: "round",
5372
+ background: "transparent",
5373
+ color: C.muted,
5374
+ // hover-only: the row raises this to 1 and the transition fades it in
5375
+ // and out, so the control never appears or vanishes with a snap
5376
+ opacity: 0,
5377
+ cursor: "pointer",
5378
+ transition: "opacity 0.22s ease, background 0.18s ease, color 0.18s ease"
5379
+ },
5380
+ children: react_jsx_runtime.jsx("svg", {
5381
+ width: 12,
5382
+ height: 12,
5383
+ viewBox: "0 0 12 12",
5384
+ style: { display: "block" },
5385
+ children: react_jsx_runtime.jsx("path", {
5386
+ // an arrow down onto a floor line: "go to where this section stops"
5387
+ d: "M6 1.5V8M2.8 5.2 6 8.4l3.2-3.2M2 11h8",
5388
+ fill: "none",
5389
+ stroke: "currentColor",
5390
+ strokeWidth: 1.5,
5391
+ strokeLinecap: "round",
5392
+ strokeLinejoin: "round"
5393
+ })
5394
+ })
5395
+ }, attrs), "end-" + (key || "") + "-" + (idx !== undefined && idx !== null ? idx : turn));
5396
+ }
5397
+
3715
5398
  function renderItem(n, depth, jump, C, uid, extra) {
3716
5399
  var hasChildren = !!(n.children && n.children.length);
3717
5400
  var path = extra && extra.paths ? extra.paths[headingId(n)] : null;
5401
+ // Wide (curtain) rows put the same information on ONE line: an H-level chip,
5402
+ // the title, and the section's opening text out to the right — the space the
5403
+ // docked panel does not have. The narrow panel keeps title-over-subtitle.
5404
+ var wide = !!(extra && extra.wide);
3718
5405
  var hoverInfo = {
3719
5406
  title: n.title,
3720
5407
  sub: n.sub || "",
@@ -3722,57 +5409,124 @@ window.__ModuleLoader__.load({
3722
5409
  meta: [n.time, path ? path.path : ""].filter(Boolean).join(" · "),
3723
5410
  ghost: false
3724
5411
  };
3725
- var rows = [
3726
- react_jsx_runtime.jsx("div", {
3727
- style: { display: "flex", alignItems: "center", gap: 2, minWidth: 0 },
3728
- children: react_jsx_runtime.jsx("span", {
3729
- style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
3730
- children: n.title
3731
- })
3732
- }, uid + "-l")
3733
- ];
3734
- // Subtitle = the section's first sentence. This is what makes two headings
3735
- // with the SAME text distinguishable without hovering ("which one is this?").
3736
- if (n.sub) {
3737
- rows.push(react_jsx_runtime.jsx("div", {
3738
- style: {
3739
- fontSize: 10,
3740
- lineHeight: "13px",
3741
- color: C.muted,
3742
- opacity: 0.75,
3743
- whiteSpace: "nowrap",
3744
- overflow: "hidden",
3745
- textOverflow: "ellipsis"
3746
- },
3747
- children: n.sub
3748
- }, uid + "-s"));
5412
+ var rows;
5413
+ if (wide) {
5414
+ var context = n.preview || n.sub || "";
5415
+ rows = [react_jsx_runtime.jsx("div", {
5416
+ style: { display: "flex", alignItems: "baseline", gap: 8, minWidth: 0 },
5417
+ children: [
5418
+ react_jsx_runtime.jsx("span", {
5419
+ style: { flex: "none", width: 22, fontSize: 11.5, fontWeight: 600, letterSpacing: 0.2, color: C.muted, opacity: 0.65 },
5420
+ children: "H" + n.level
5421
+ }, uid + "-lv"),
5422
+ react_jsx_runtime.jsx("span", {
5423
+ style: { flex: "0 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
5424
+ children: n.title
5425
+ }),
5426
+ // basis 0 + grow: the small text only ever takes the space the title
5427
+ // leaves over, so a long title is never squeezed by its own context
5428
+ context ? react_jsx_runtime.jsx("span", {
5429
+ style: {
5430
+ flex: "1 1 0",
5431
+ minWidth: 0,
5432
+ fontSize: 13,
5433
+ color: C.muted,
5434
+ opacity: 0.68,
5435
+ textAlign: "right",
5436
+ overflow: "hidden",
5437
+ textOverflow: "ellipsis"
5438
+ },
5439
+ children: context
5440
+ }) : null
5441
+ ]
5442
+ }, uid + "-l")];
5443
+ } else {
5444
+ rows = [
5445
+ react_jsx_runtime.jsx("div", {
5446
+ style: { display: "flex", alignItems: "center", gap: 2, minWidth: 0 },
5447
+ children: react_jsx_runtime.jsx("span", {
5448
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
5449
+ children: n.title
5450
+ })
5451
+ }, uid + "-l")
5452
+ ];
5453
+ // Subtitle = the section's first sentence. This is what makes two headings
5454
+ // with the SAME text distinguishable without hovering ("which one is this?").
5455
+ if (n.sub) {
5456
+ rows.push(react_jsx_runtime.jsx("div", {
5457
+ style: {
5458
+ fontSize: 10,
5459
+ lineHeight: "13px",
5460
+ color: C.muted,
5461
+ opacity: 0.75,
5462
+ whiteSpace: "nowrap",
5463
+ overflow: "hidden",
5464
+ textOverflow: "ellipsis"
5465
+ },
5466
+ children: n.sub
5467
+ }, uid + "-s"));
5468
+ }
3749
5469
  }
5470
+ // the section's own "jump to its end" control, revealed while the row is
5471
+ // hovered; the reserved right padding keeps the title/subtitle/context
5472
+ // clear of it so nothing ever runs underneath the button
5473
+ rows.push(renderEndBtn(C, n.key, n.idx));
5474
+ // The hover highlight is its own layer rather than the row's own background:
5475
+ // the row box runs to its right edge, which is exactly where the end control's
5476
+ // round backdrop appears, so the two boxes used to sit flush against each other
5477
+ // (and on a long row the highlight ran underneath the button). This layer stops
5478
+ // END_ZONE short of that edge, leaving a clear gap.
5479
+ rows.unshift(react_jsx_runtime.jsx("div", {
5480
+ "data-dqt-hl": "1",
5481
+ style: {
5482
+ position: "absolute",
5483
+ left: 0,
5484
+ top: 0,
5485
+ bottom: 0,
5486
+ right: END_ZONE,
5487
+ borderRadius: 6,
5488
+ background: "transparent",
5489
+ transition: "background 0.15s ease",
5490
+ pointerEvents: "none"
5491
+ }
5492
+ }, uid + "-hl"));
3750
5493
  return react_jsx_runtime.jsx(
3751
5494
  "div",
3752
5495
  {
3753
5496
  onClick: function () { jump(n.key, n.idx); },
3754
5497
  onMouseEnter: function (e) {
3755
- e.currentTarget.style.background = C.hover;
5498
+ var hl = e.currentTarget.querySelector("[data-dqt-hl]");
5499
+ if (hl) hl.style.background = C.hover;
5500
+ var b = e.currentTarget.querySelector("[data-dqt-end]");
5501
+ if (b) b.style.opacity = "1";
3756
5502
  if (extra && extra.hoverStart) extra.hoverStart(hoverInfo, e.currentTarget);
3757
5503
  },
3758
5504
  onMouseLeave: function (e) {
3759
- e.currentTarget.style.background = "transparent";
5505
+ var hl = e.currentTarget.querySelector("[data-dqt-hl]");
5506
+ if (hl) hl.style.background = "transparent";
5507
+ var b = e.currentTarget.querySelector("[data-dqt-end]");
5508
+ if (b) b.style.opacity = "0";
3760
5509
  if (extra && extra.hoverEnd) extra.hoverEnd();
3761
5510
  },
3762
5511
  "data-jump-key": n.key,
3763
5512
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
5513
+ // keyboard navigation walks the ORDER of these rows inside the list
5514
+ "data-nav-row": "1",
3764
5515
  style: {
3765
5516
  // no fixed height: a row grows by one line when it has a subtitle
5517
+ // (narrow layout) — in wide mode it is one line by construction
3766
5518
  display: "block",
3767
- padding: "2px 6px",
3768
- paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
5519
+ position: "relative",
5520
+ padding: wide ? "4px 8px" : "2px 6px",
5521
+ paddingRight: END_ZONE,
5522
+ paddingLeft: (hasChildren ? (wide ? 4 : 2) : (wide ? 8 : 6)) + (n.level - 1) * (wide ? 16 : 12),
3769
5523
  margin: "1px 0",
3770
5524
  borderRadius: 6,
3771
5525
  cursor: "pointer",
3772
- fontSize: n.level <= 2 ? 13 : 12,
5526
+ fontSize: n.level <= 2 ? (wide ? 16.5 : 13) : (wide ? 15.5 : 12),
3773
5527
  color: n.level <= 2 ? C.text : C.muted,
3774
5528
  fontWeight: n.level <= 2 ? 600 : 400,
3775
- lineHeight: "18px",
5529
+ lineHeight: wide ? "26px" : "18px",
3776
5530
  whiteSpace: "nowrap",
3777
5531
  overflow: "hidden"
3778
5532
  },
@@ -3813,43 +5567,84 @@ window.__ModuleLoader__.load({
3813
5567
 
3814
5568
  function renderResultRow(r, i, q, C, onRow, isActive, extra) {
3815
5569
  var fuzzy = !!(extra && extra.fuzzy);
5570
+ // the curtain's rows are wide: title, snippet and meta share ONE line (title
5571
+ // left, context middle-right, meta far right) instead of stacking three
5572
+ // half-empty lines. The docked panel keeps the stacked form.
5573
+ var wide = !!(extra && extra.wide);
3816
5574
  var meta = [];
3817
5575
  if (r.ghost) meta.push(T("turn.unloaded"));
3818
5576
  if (r.failed) meta.push(T("turn.failed"));
3819
5577
  if (r.time) meta.push(r.time);
3820
5578
  if (r.path) meta.push(r.path);
3821
5579
  if (r.count > 1) meta.push("×" + r.count);
3822
- var children = [
3823
- react_jsx_runtime.jsx("div", {
3824
- style: {
3825
- fontSize: 12,
3826
- fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
3827
- color: r.level > 0 ? C.text : C.muted,
3828
- whiteSpace: "nowrap",
3829
- overflow: "hidden",
3830
- textOverflow: "ellipsis"
3831
- },
3832
- children: hitSpans(r.title, q, fuzzy)
3833
- }, "t" + i)
3834
- ];
3835
- if (r.snippet) {
3836
- children.push(react_jsx_runtime.jsx("div", {
3837
- style: {
3838
- fontSize: 11,
3839
- color: C.muted,
3840
- marginTop: 1,
3841
- whiteSpace: "nowrap",
3842
- overflow: "hidden",
3843
- textOverflow: "ellipsis"
3844
- },
3845
- children: hitSpans(r.snippet, q, fuzzy)
3846
- }, "s" + i));
3847
- }
3848
- if (meta.length) {
3849
- children.push(react_jsx_runtime.jsx("div", {
3850
- style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
3851
- children: meta.join(" · ")
3852
- }, "m" + i));
5580
+ var children;
5581
+ if (wide) {
5582
+ children = [
5583
+ react_jsx_runtime.jsx("span", {
5584
+ style: {
5585
+ flex: "0 1 auto",
5586
+ minWidth: 0,
5587
+ fontSize: 15.5,
5588
+ fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
5589
+ color: r.level > 0 ? C.text : C.muted,
5590
+ whiteSpace: "nowrap",
5591
+ overflow: "hidden",
5592
+ textOverflow: "ellipsis"
5593
+ },
5594
+ children: hitSpans(r.title, q, fuzzy)
5595
+ }, "t" + i),
5596
+ r.snippet ? react_jsx_runtime.jsx("span", {
5597
+ style: {
5598
+ flex: "1 1 0",
5599
+ minWidth: 0,
5600
+ fontSize: 13,
5601
+ color: C.muted,
5602
+ opacity: 0.85,
5603
+ textAlign: "right",
5604
+ whiteSpace: "nowrap",
5605
+ overflow: "hidden",
5606
+ textOverflow: "ellipsis"
5607
+ },
5608
+ children: hitSpans(r.snippet, q, fuzzy)
5609
+ }, "s" + i) : null,
5610
+ meta.length ? react_jsx_runtime.jsx("span", {
5611
+ style: { flex: "none", fontSize: 12, color: C.muted, opacity: 0.7, whiteSpace: "nowrap" },
5612
+ children: meta.join(" · ")
5613
+ }, "m" + i) : null
5614
+ ];
5615
+ } else {
5616
+ children = [
5617
+ react_jsx_runtime.jsx("div", {
5618
+ style: {
5619
+ fontSize: 12,
5620
+ fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
5621
+ color: r.level > 0 ? C.text : C.muted,
5622
+ whiteSpace: "nowrap",
5623
+ overflow: "hidden",
5624
+ textOverflow: "ellipsis"
5625
+ },
5626
+ children: hitSpans(r.title, q, fuzzy)
5627
+ }, "t" + i)
5628
+ ];
5629
+ if (r.snippet) {
5630
+ children.push(react_jsx_runtime.jsx("div", {
5631
+ style: {
5632
+ fontSize: 11,
5633
+ color: C.muted,
5634
+ marginTop: 1,
5635
+ whiteSpace: "nowrap",
5636
+ overflow: "hidden",
5637
+ textOverflow: "ellipsis"
5638
+ },
5639
+ children: hitSpans(r.snippet, q, fuzzy)
5640
+ }, "s" + i));
5641
+ }
5642
+ if (meta.length) {
5643
+ children.push(react_jsx_runtime.jsx("div", {
5644
+ style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
5645
+ children: meta.join(" · ")
5646
+ }, "m" + i));
5647
+ }
3853
5648
  }
3854
5649
  return react_jsx_runtime.jsx("div", {
3855
5650
  // clicking a row makes that hit the CURRENT one: it highlights the
@@ -3873,13 +5668,18 @@ window.__ModuleLoader__.load({
3873
5668
  if (extra && extra.hoverEnd) extra.hoverEnd();
3874
5669
  },
3875
5670
  "data-result-idx": i,
5671
+ "data-nav-row": "1",
3876
5672
  title: r.path || r.title,
3877
5673
  style: {
3878
- padding: "4px 6px",
5674
+ padding: wide ? "6px 10px" : "4px 6px",
3879
5675
  margin: "1px 0",
3880
5676
  borderRadius: 6,
3881
5677
  cursor: "pointer",
3882
5678
  minWidth: 0,
5679
+ // wide: one line, three parts — level chip / title, context, meta
5680
+ display: wide ? "flex" : "block",
5681
+ alignItems: wide ? "baseline" : undefined,
5682
+ gap: wide ? 10 : undefined,
3883
5683
  background: isActive ? C.chip : "transparent",
3884
5684
  transition: "background 0.15s ease"
3885
5685
  },
@@ -3901,13 +5701,86 @@ window.__ModuleLoader__.load({
3901
5701
  return out;
3902
5702
  }
3903
5703
 
5704
+ // cross-session list: the HOST's index over the other sessions. One row per hit —
5705
+ // the hit session's title, the host's bounded snippet, and the scope note — and
5706
+ // clicking a row hands that session to openCrossHit. No hover card here: the
5707
+ // panel has no section text for a session it has not loaded.
5708
+ function renderCrossResults(cross, q, C, titleOf, onOpen) {
5709
+ var line = function (text, key, color, size) {
5710
+ return react_jsx_runtime.jsx("div", {
5711
+ style: {
5712
+ padding: "10px 8px",
5713
+ fontSize: size || 12,
5714
+ color: color || C.muted,
5715
+ textAlign: "center",
5716
+ lineHeight: 1.5,
5717
+ overflowWrap: "anywhere"
5718
+ },
5719
+ children: text
5720
+ }, key);
5721
+ };
5722
+ var out = [
5723
+ react_jsx_runtime.jsx("div", {
5724
+ key: "cross-note",
5725
+ style: { padding: "2px 8px 6px", fontSize: 10, color: C.muted, opacity: 0.8, lineHeight: 1.45 },
5726
+ children: T("search.cross.note")
5727
+ })
5728
+ ];
5729
+ if (cross.phase === "loading") {
5730
+ out.push(line(T("search.cross.loading"), "cross-loading"));
5731
+ return out;
5732
+ }
5733
+ if (cross.phase === "error") {
5734
+ out.push(line(T("search.cross.error") + cross.error, "cross-error", C.error || C.muted));
5735
+ return out;
5736
+ }
5737
+ if (!cross.rows || cross.rows.length === 0) {
5738
+ out.push(line(T("search.cross.empty"), "cross-empty"));
5739
+ if (cross.hidden > 0) {
5740
+ out.push(line(Tp("search.cross.hidden", { n: cross.hidden }), "cross-hidden", C.muted, 10));
5741
+ }
5742
+ return out;
5743
+ }
5744
+ var rows = cross.rows;
5745
+ var open = function (i) { onOpen(rows[i].sessionId); };
5746
+ for (var i = 0; i < rows.length; i++) {
5747
+ out.push(renderResultRow({
5748
+ title: titleOf(rows[i].sessionId),
5749
+ snippet: rows[i].snippet,
5750
+ level: 0,
5751
+ path: "",
5752
+ time: "",
5753
+ count: 1
5754
+ }, i, q, C, open, false, null));
5755
+ }
5756
+ if (cross.more) {
5757
+ out.push(react_jsx_runtime.jsx("div", {
5758
+ key: "cross-more",
5759
+ style: { padding: "8px 8px 2px", fontSize: 10, color: C.muted, opacity: 0.8, textAlign: "center" },
5760
+ children: T("search.cross.more")
5761
+ }));
5762
+ }
5763
+ // Hits the panel refuses to offer (archived / unlisted / subagent) are counted,
5764
+ // not silently dropped: "why is my hit missing" must have an answer on screen.
5765
+ if (cross.hidden > 0) {
5766
+ out.push(line(Tp("search.cross.hidden", { n: cross.hidden }), "cross-hidden", C.muted, 10));
5767
+ }
5768
+ return out;
5769
+ }
5770
+
3904
5771
  // a group's header (the time row): clicking it jumps to the TOP OF THE
3905
5772
  // MODEL'S REPLY for that turn — the first assistant step — which also makes
3906
5773
  // heading-less turns jumpable, since they only have this row.
3907
5774
  // Implemented as a function (not an inline closure in a loop) so each
3908
5775
  // header captures its own (g, gi) — the var-in-loop closure bug would
3909
5776
  // otherwise make every header jump to the last group.
3910
- function renderGroupHeader(g, gi, jump, C, isActive) {
5777
+ function renderGroupHeader(g, gi, jump, C, isActive, extra) {
5778
+ // questions-only view and the wide curtain both give the header more room: it
5779
+ // grows (11px -> 14px, 18px -> 24px) and its prompt stops being a footnote
5780
+ // (0.75 -> 0.92 opacity on the body colour). The current-turn box is untouched
5781
+ // either way, and every header in a given view still shares ONE style.
5782
+ var big = !!(extra && (extra.questionsOnly || extra.wide));
5783
+ var wideBand = !!(extra && extra.wide && !extra.questionsOnly);
3911
5784
  // a turn that ended in a failure has no reply to land on: its terminal row IS
3912
5785
  // the interesting position, so the header lands there (and says so in the tip)
3913
5786
  var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : (g.failure ? g.failure.key : "");
@@ -3919,14 +5792,32 @@ window.__ModuleLoader__.load({
3919
5792
  if (g.userKey && findRow(g.userKey)) { jump(g.userKey); return; }
3920
5793
  if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
3921
5794
  };
5795
+ // the group's own "jump to its end": the turn's last row (the failure row for
5796
+ // a failed turn, else the last reply). Revealed while the header is hovered.
5797
+ var lastKey = (g.msgs && g.msgs.length > 0) ? g.msgs[g.msgs.length - 1].key
5798
+ : (g.failure ? g.failure.key : "");
3922
5799
  return react_jsx_runtime.jsx("div", {
5800
+ onMouseEnter: function (e) {
5801
+ var b = e.currentTarget.querySelector("[data-dqt-end]");
5802
+ if (b) b.style.opacity = "1";
5803
+ },
5804
+ onMouseLeave: function (e) {
5805
+ var b = e.currentTarget.querySelector("[data-dqt-end]");
5806
+ if (b) b.style.opacity = "0";
5807
+ },
3923
5808
  style: {
3924
5809
  // ONE row geometry for every group header, whether or not the turn has
3925
5810
  // headings: same box, same 18px rhythm, same left inset, so the time
3926
5811
  // column of a heading-less turn lines up with all the others. Only the
3927
5812
  // type emphasis differs (weight + colour, see the label below).
3928
- padding: "1px 4px 2px",
3929
- height: 18,
5813
+ padding: big ? (wideBand ? "3px 4px 4px" : "2px 4px 3px") : "1px 4px 2px",
5814
+ // the label is capped by this, not by a maxWidth guess: the row reserves the
5815
+ // end control's zone, so a long prompt's hover box ends END_ZONE from the row
5816
+ // edge — a real gap before the button's round backdrop, which sits 24px in.
5817
+ // (With `max-width: calc(100% - 32px)` the percentage resolved somewhere else
5818
+ // and a forced-long header still came within 2px of the circle.)
5819
+ paddingRight: END_ZONE,
5820
+ height: big ? (wideBand ? 24 : 22) : 18,
3930
5821
  display: "flex",
3931
5822
  alignItems: "center",
3932
5823
  minWidth: 0,
@@ -3957,6 +5848,7 @@ window.__ModuleLoader__.load({
3957
5848
  react_jsx_runtime.jsx("span", {
3958
5849
  onClick: jumpToTurn,
3959
5850
  title: (g.msgs && g.msgs.length > 0) ? T("turn.jumpReply") : (g.failure ? T("turn.jumpFailed") : T("turn.jumpTurn")),
5851
+ "data-nav-row": "1",
3960
5852
  onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
3961
5853
  onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
3962
5854
  style: {
@@ -3966,11 +5858,11 @@ window.__ModuleLoader__.load({
3966
5858
  // with the rest of the list (same size, weight, colour, opacity). The only
3967
5859
  // thing that marks a group is the shared "current group" tint, which lives
3968
5860
  // on the group box — never on the label.
3969
- fontSize: 11,
5861
+ fontSize: big ? (wideBand ? 14 : 12.5) : 11,
3970
5862
  fontWeight: 400,
3971
- color: C.muted,
5863
+ color: big ? C.text : C.muted,
3972
5864
  cursor: "pointer",
3973
- padding: "1px 5px",
5865
+ padding: big ? "1px 5px" : "1px 5px",
3974
5866
  borderRadius: 4,
3975
5867
  background: "transparent",
3976
5868
  transition: "background 0.15s ease",
@@ -3978,6 +5870,8 @@ window.__ModuleLoader__.load({
3978
5870
  alignItems: "center",
3979
5871
  gap: 5,
3980
5872
  minWidth: 0,
5873
+ // the row's paddingRight already reserves the end control's zone, so the
5874
+ // label simply fills what is left: its hover box can never reach the circle
3981
5875
  maxWidth: "100%",
3982
5876
  overflow: "hidden"
3983
5877
  },
@@ -3986,7 +5880,7 @@ window.__ModuleLoader__.load({
3986
5880
  g.userText ? react_jsx_runtime.jsx("span", {
3987
5881
  style: {
3988
5882
  fontWeight: 400,
3989
- opacity: 0.75,
5883
+ opacity: big ? 0.92 : 0.75,
3990
5884
  overflow: "hidden",
3991
5885
  textOverflow: "ellipsis",
3992
5886
  whiteSpace: "nowrap",
@@ -3995,7 +5889,8 @@ window.__ModuleLoader__.load({
3995
5889
  children: g.userText
3996
5890
  }) : null
3997
5891
  ]
3998
- })
5892
+ }),
5893
+ renderEndBtn(C, lastKey, undefined, g.turn)
3999
5894
  ]
4000
5895
  }, "g-h-" + gi);
4001
5896
  }
@@ -4040,6 +5935,7 @@ window.__ModuleLoader__.load({
4040
5935
  },
4041
5936
  "data-group-idx": gi,
4042
5937
  "data-ghost-turn": g.turn,
5938
+ "data-nav-row": "1",
4043
5939
  title: T("turn.loadTip"),
4044
5940
  style: {
4045
5941
  padding: "3px 6px",
@@ -4131,8 +6027,10 @@ window.__ModuleLoader__.load({
4131
6027
  // every group gets the SAME header, whether or not this turn produced any
4132
6028
  // markdown headings — a heading-less turn is just a group with no rows, not
4133
6029
  // a second kind of entry (`rows` only decides what gets listed below it)
4134
- items.push(renderGroupHeader(g, gi, jump, C, isActive));
4135
- items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
6030
+ items.push(renderGroupHeader(g, gi, jump, C, isActive, extra));
6031
+ // questions-only reading: the header IS the content, so its rows are left out
6032
+ // entirely (the header itself grows and brightens for that job)
6033
+ if (!extra.questionsOnly) items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
4136
6034
  // the failure is the turn's LAST event, so its row sits under the rows
4137
6035
  if (g.failure) items.push(renderFailureRow(g, gi, jump, C));
4138
6036
  }
@@ -4358,6 +6256,7 @@ window.__ModuleLoader__.load({
4358
6256
  field("handle", T("settings.handle"), T("settings.handle.tip"), handleControl, false),
4359
6257
  field("fuzzy", T("settings.fuzzy"), "", onoff(prefs.fuzzy, function (v) { commit({ fuzzy: v }); }), true),
4360
6258
  field("hover", T("settings.hover"), "", onoff(prefs.hover, function (v) { commit({ hover: v }); }), true),
6259
+ field("remember", T("settings.remember"), T("settings.remember.tip"), onoff(prefs.remember, function (v) { commit({ remember: v }); }), true),
4361
6260
  field("debug", T("settings.debug"), T("settings.debug.tip"), onoff(prefs.debug, function (v) { commit({ debug: v }); }), true, true)
4362
6261
  ];
4363
6262
  return react_jsx_runtime.jsxs("li", {
@@ -4459,6 +6358,16 @@ window.__ModuleLoader__.load({
4459
6358
  } catch (e) {
4460
6359
  return null;
4461
6360
  }
6361
+ },
6362
+ // The workspace registry owns the archive set the sidebar filters with
6363
+ // (list.getSnapshot().archivedSessionIds). Cross-session hits are filtered
6364
+ // by the SAME rule, so a hit the sidebar would hide is never offered here.
6365
+ workspaces: function () {
6366
+ try {
6367
+ return typeof ctx.get === "function" ? (ctx.get("workspaces") || null) : null;
6368
+ } catch (e) {
6369
+ return null;
6370
+ }
4462
6371
  }
4463
6372
  };
4464
6373
 
@@ -4534,6 +6443,43 @@ window.__ModuleLoader__.load({
4534
6443
  "@keyframes dqt-toast-out{from{opacity:1;transform:none}to{opacity:0;transform:none}}" +
4535
6444
  ".dqt-toast{animation:dqt-toast-in 0.2s cubic-bezier(0.22, 0.9, 0.3, 1) both}" +
4536
6445
  ".dqt-toast-closing{animation:dqt-toast-out 0.42s linear both}" +
6446
+ // questions-only switch: fade the list out, swap the mode while it is
6447
+ // invisible, fade it back in (the two lists never blend mid-swap)
6448
+ ".dqt-mode-out{animation:dqt-fade-out 0.15s linear both}" +
6449
+ ".dqt-mode-in{animation:dqt-fade-in 0.26s cubic-bezier(0.22, 0.9, 0.3, 1) both}" +
6450
+ // the curtain FLIES DOWN from above the line it hangs from and flies back up
6451
+ // on the way out — the same vocabulary as the docked panel sliding in from its
6452
+ // edge. The clip box it lives in (see the portal) is what keeps it from being
6453
+ // seen above that line, so it reads as emerging from under the view tabs.
6454
+ "@keyframes dqt-sheet-in{from{transform:translateY(-100%)}to{transform:translateY(0)}}" +
6455
+ "@keyframes dqt-sheet-out{from{transform:translateY(0)}to{transform:translateY(-100%)}}" +
6456
+ ".dqt-sheet-open{animation:dqt-sheet-in 0.46s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform}" +
6457
+ ".dqt-sheet-closing{animation:dqt-sheet-out 0.3s cubic-bezier(0.4, 0, 1, 1) both;pointer-events:none;will-change:transform}" +
6458
+ "@media (prefers-reduced-motion: reduce){.dqt-sheet-open,.dqt-sheet-closing{animation:none}}" +
6459
+ // The curtain handle is a droplet resting on the view-tab strip's lower line:
6460
+ // its silhouette flares into two concave fillets at the line (surface tension,
6461
+ // not a detached pill). It grows out of the line when it appears — after a
6462
+ // retract as much as on first paint — and the click squeezes it flat against
6463
+ // the line before the curtain takes over (see the element's own transform).
6464
+ "@keyframes dqt-handle-pop{from{transform:scaleY(0.06)}to{transform:scaleY(1)}}" +
6465
+ ".dqt-sheet-handle{transform-origin:50% 100%}" +
6466
+ // coming back it is the press read backwards: FAST OUT OF THE LINE — 74% of the
6467
+ // easing is done 30ms in — and then the rest settles over the remaining 170ms.
6468
+ // `cubic-bezier(0, 0.55, 0.25, 1)` is exactly `cubic-bezier(0.75, 0, 1, 0.45)`
6469
+ // mirrored, which is the curve the press uses.
6470
+ ".dqt-sheet-handle>svg{animation:dqt-handle-pop 0.2s cubic-bezier(0, 0.55, 0.25, 1) both;transform-origin:50% 100%}" +
6471
+ "@media (prefers-reduced-motion: reduce){.dqt-sheet-handle>svg{animation:none}}" +
6472
+ // keyboard cursor: an INSET outline plus a whisper of fill, so it never
6473
+ // reflows the row and stays distinct from the reading position's blue box
6474
+ // (which keeps the 3px left accent bar and the pastel fill)
6475
+ ".dqt-nav{box-shadow:inset 0 0 0 1px var(--dsw-alias-brand-primary, #4f8cff);background:rgba(79,140,255,0.10);border-radius:6px}" +
6476
+ ".dqt-nav:focus{outline:none}" +
6477
+ // the list itself never draws a focus ring — the cursor row is the indicator
6478
+ ".dqt-list:focus-visible{outline:none}" +
6479
+ // screen-reader-only live region
6480
+ ".dqt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}" +
6481
+ // the fade is decoration: a reader who asked for less motion gets the swap
6482
+ "@media (prefers-reduced-motion: reduce){.dqt-mode-out,.dqt-mode-in{animation:none}}" +
4537
6483
  // exit: starts moving immediately (fast attack), so the dismiss feels snappy
4538
6484
  ".dqt-levels-pop-closing{animation:dqt-pop-out 0.14s cubic-bezier(0.2, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
4539
6485
  // Hide the native scrollbar entirely (no arrow buttons, no grey bar) —