dsh-select-quote 0.3.8 → 0.3.10

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
@@ -28,6 +28,20 @@ function composerCardOf(node) {
28
28
  function willSubmit(card, draft) {
29
29
  return draft.trim().length > 0 || card.querySelector("img") !== null;
30
30
  }
31
+ /**
32
+ * True for the Enter gestures the host submits: plain Enter, Ctrl+Enter, and
33
+ * Meta+Enter. Mirrors the host composer's Enter command: Shift+Enter inserts
34
+ * a newline; Alt, AltGraph, the two modifiers together, and IME composition
35
+ * are ignored. Used to fold annotations for every real submit gesture.
36
+ */
37
+ function isSubmitKey(event) {
38
+ if (event.key !== "Enter") return false;
39
+ if (event.shiftKey || event.altKey) return false;
40
+ if (event.ctrlKey && event.metaKey) return false;
41
+ if (event.isComposing) return false;
42
+ if (event.getModifierState?.("AltGraph") === true) return false;
43
+ return true;
44
+ }
31
45
  /** Focus the resident composer editor and park the caret at the end. */
32
46
  function focusComposer() {
33
47
  const editor = document.querySelector("[data-lexical-editor=\"true\"]") ?? document.querySelector("[contenteditable=\"true\"][data-lexical-editor]") ?? lastEditable();
@@ -78,7 +92,7 @@ const ATOMS = {
78
92
  };
79
93
  const toKebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
80
94
  const atomRules = Object.entries(ATOMS).map(([name, declarations]) => `.dsq_a_${toKebab(name)} { ${declarations} }`).join("\n");
81
- /** Atom class names, for `cx(styles.card, A.row)`. */
95
+ /** Atom class names, for `cx(styles.summary, A.row)`. */
82
96
  const A = Object.fromEntries(Object.keys(ATOMS).map((name) => [name, `dsq_a_${toKebab(name)}`]));
83
97
  /** Join class parts, dropping the falsy ones so a variant can be `cond && A.x`. */
84
98
  function cx(...parts) {
@@ -96,9 +110,16 @@ body {
96
110
  --dsq-font-delta: var(--dsh-content-font-delta, 0px);
97
111
  --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
98
112
  --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
113
+ /* The settings switch mirrors the Host's own switch: neutral track when off,
114
+ brand fill when on, and a thumb that inverts with the theme. */
115
+ --dsq-switch-off: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.12));
116
+ --dsq-switch-on: var(--dsw-alias-brand-primary, #0f1115);
117
+ --dsq-switch-thumb-off: var(--dsw-alias-switch-thumb, #fff);
118
+ --dsq-switch-thumb-on: var(--dsw-alias-label-primary-foreground, #fff);
99
119
  --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
100
120
  --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
101
121
  --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
122
+ --dsq-focus-ring-soft: color-mix(in srgb, var(--dsq-accent) 50%, transparent);
102
123
  --dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
103
124
  --dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
104
125
  --dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
@@ -108,11 +129,14 @@ body {
108
129
  --dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
109
130
  --dsq-menu: var(--dsw-menu-surface-fill, rgba(248, 249, 250, .58));
110
131
  --dsq-menu-backdrop: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
132
+ --dsq-scrim: rgba(0, 0, 0, 0.32);
111
133
  --dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
112
134
  --dsq-accent: var(--dsw-static-blue-500, #3b82f6);
113
135
  --dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
114
136
  --dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
115
137
  --dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
138
+ --dsq-summary-bg: var(--dsw-alias-bg-layer-2, #fff);
139
+ --dsq-mark-hover: var(--dsw-static-blue-75, #e5f0ff);
116
140
  --dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
117
141
  --dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
118
142
  --dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
@@ -160,6 +184,26 @@ ${atomRules}
160
184
  }
161
185
  }
162
186
 
187
+ /* Right-click / shortcut menu: transform only, for the same reason as the
188
+ toolbar — an animated opacity would cut the material layer's backdrop. */
189
+ @keyframes dsq-menu-in {
190
+ from {
191
+ transform: translateY(4px) scale(0.97);
192
+ }
193
+ to {
194
+ transform: translateY(0) scale(1);
195
+ }
196
+ }
197
+
198
+ @keyframes dsq-menu-in-up {
199
+ from {
200
+ transform: translateY(-4px) scale(0.97);
201
+ }
202
+ to {
203
+ transform: translateY(0) scale(1);
204
+ }
205
+ }
206
+
163
207
  /* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
164
208
  @keyframes dsq-rise-in {
165
209
  from {
@@ -170,15 +214,29 @@ ${atomRules}
170
214
  }
171
215
  }
172
216
 
217
+ /* Exit of the chip preview: the same 4px travel, reversed. Transform only, so
218
+ the material layer keeps sampling its backdrop until the node is removed. */
219
+ @keyframes dsq-rise-out {
220
+ from {
221
+ transform: translateY(0);
222
+ }
223
+ to {
224
+ transform: translateY(4px);
225
+ }
226
+ }
227
+
173
228
  /* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
174
229
  --dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
175
230
  画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
176
- 面板里的文字一起糊掉。工具条、评论气泡、悬停面板三个浮层共用这一条。
231
+ 面板里的文字一起糊掉。所有浮层共用这一条。
177
232
  注意:macOS 上宿主另有一个 --dsw-specific-menu(覆写成 94% 不透明)给它的
178
233
  菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
179
234
  .dsq_toolbar::before,
180
235
  .dsq_commentPop::before,
181
- .dsq_summaryHoverInner::before {
236
+ .dsq_summaryHoverInner::before,
237
+ .dsq_touchBar::before,
238
+ .dsq_chipPreview::before,
239
+ .dsq_ctxMenu::before {
182
240
  content: "";
183
241
  position: absolute;
184
242
  inset: 0;
@@ -193,7 +251,7 @@ ${atomRules}
193
251
  /* 覆层不描边:elevation-prominent 自带的那圈 0 0 0 .5px(--dsw-elevation-stroke-color,
194
252
  默认 border-l4)对磨砂覆层是多余的——边缘自带材质分界,再勾一道反而抢视觉,覆写成
195
253
  更淡的 border-l1。
196
- 两个坑,改之前先读:① 这两行必须写在三个浮层自己的选择器上。写在 body 上会随继承
254
+ 两个坑,改之前先读:① 这两行必须写在浮层自己的选择器上。写在 body 上会随继承
197
255
  影响页面里其它依赖默认值的面板(宿主带 [data-menu-material] 的菜单有自己的覆写,
198
256
  不受影响)。② --dsq-shadow 也必须在这儿声明,不能留在 body:自定义属性是在**声明处**
199
257
  展开的,--dsq-shadow 留在 body 上时 var(--dsw-elevation-prominent) 会在 body 就被
@@ -201,9 +259,16 @@ ${atomRules}
201
259
  整条链才在浮层元素上取值。 */
202
260
  .dsq_toolbar,
203
261
  .dsq_commentPop,
204
- .dsq_summaryHoverInner {
262
+ .dsq_summaryHoverInner,
263
+ .dsq_touchBar,
264
+ .dsq_touchSheet,
265
+ .dsq_chipPreview,
266
+ .dsq_ctxMenu {
205
267
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
206
268
  --dsq-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
269
+ /* Every surface above takes the same elevation; declaring it once here is
270
+ what keeps the component blocks free of a copied box-shadow. */
271
+ box-shadow: var(--dsq-shadow);
207
272
  }
208
273
 
209
274
  /* 浮层挂载与层带(2026-10-20 定,改前先读):
@@ -219,8 +284,10 @@ ${atomRules}
219
284
  现在把两件事交回宿主布局:
220
285
  - 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
221
286
  下面,和正文一致;
222
- - host 的 z-index 固定 6(见 floating-layer.ts):低于 composer 的 7,高于
287
+ - host 的 z-index 平时 6(见 floating-layer.ts):低于 composer 的 7,高于
223
288
  内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
289
+ 评论步骤打开期间抬到 8:摘要卡在 composer 座位里(z 7),会伸进滚动区,
290
+ 6 的评论泡会被它盖住;关闭评论即还原,空闲工具条仍不压输入框。
224
291
  host 内部次序:status 3 > toolbar 2 > commentPop 1。
225
292
 
226
293
  摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
@@ -241,8 +308,7 @@ ${atomRules}
241
308
  由它勾勒轮廓,所以这里不要再画 border,否则描边会和环叠成两条线。 */
242
309
  border: 0;
243
310
  border-radius: 12px;
244
- /* 底色与模糊都在上面的材质图层里,这里不再画背景 */
245
- box-shadow: var(--dsq-shadow);
311
+ /* 底色与模糊都在上面的材质图层里,这里不再画背景;elevation 在共享规则里 */
246
312
  pointer-events: auto;
247
313
  /* A/B:scale 入场放在容器上(材质图层归它所有)。关键帧自带 translate(-50%,-100%),
248
314
  所以缩放不会破坏居中。若真实客户端里磨砂在放大期间消失,改回内容层方案。 */
@@ -261,10 +327,9 @@ ${atomRules}
261
327
  animation-name: dsq-toolbar-in-popover-top;
262
328
  }
263
329
 
264
- /* 按钮行:只负责布局;入场动画在容器上(见 .dsq_toolbar 的 A/B 说明)。 */
330
+ /* 按钮行:形状由 A.inlineRow 提供,这里只留间距;入场动画在容器上
331
+ (见 .dsq_toolbar 的 A/B 说明)。 */
265
332
  .dsq_toolbarInner {
266
- display: inline-flex;
267
- align-items: center;
268
333
  gap: 2px;
269
334
  }
270
335
 
@@ -282,11 +347,17 @@ ${atomRules}
282
347
  background: var(--dsq-fill-hover);
283
348
  }
284
349
 
285
- .dsq_buttonAddComment {
350
+ /* Accent-filled actions: comment commit, annotation save, chip jump. Only the
351
+ metrics differ; fill and hover live here once. */
352
+ .dsq_buttonAddComment,
353
+ .dsq_annEditSave,
354
+ .dsq_chipPreviewJump {
286
355
  background: var(--dsq-accent);
287
- color:#fff;
356
+ color: #fff;
288
357
  }
289
- .dsq_buttonAddComment:hover {
358
+ .dsq_buttonAddComment:hover,
359
+ .dsq_annEditSave:hover,
360
+ .dsq_chipPreviewJump:hover {
290
361
  background: var(--dsq-accent-400);
291
362
  }
292
363
 
@@ -306,10 +377,10 @@ ${atomRules}
306
377
  margin: 0 2px;
307
378
  }
308
379
 
309
- .dsq_status {
310
- position: absolute;
311
- z-index: 3;
312
- transform: translate(-50%, 8px);
380
+ /* Toast pill: the desktop status and the touch status share the whole skin;
381
+ only where they anchor differs. */
382
+ .dsq_status,
383
+ .dsq_touchStatus {
313
384
  padding: 4px 10px;
314
385
  border-radius: 999px;
315
386
  background: var(--dsq-bg-toast);
@@ -320,6 +391,66 @@ ${atomRules}
320
391
  white-space: nowrap;
321
392
  }
322
393
 
394
+ .dsq_status {
395
+ position: absolute;
396
+ z-index: 3;
397
+ transform: translate(-50%, 8px);
398
+ }
399
+
400
+ /* Right-click / shortcut menu. Body-level fixed: it must escape the
401
+ transcript's clipping. It closes on scroll instead of following the text,
402
+ so it never points at a line that has moved away. */
403
+ .dsq_ctxMenu {
404
+ position: fixed;
405
+ z-index: 64;
406
+ width: max-content;
407
+ min-width: 172px;
408
+ max-width: min(280px, calc(100vw - 16px));
409
+ padding: 4px;
410
+ border: 0;
411
+ border-radius: 12px;
412
+ outline: none;
413
+ animation: dsq-menu-in 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
414
+ transform-origin: top left;
415
+ }
416
+
417
+ .dsq_ctxMenu[data-side='top'] {
418
+ animation-name: dsq-menu-in-up;
419
+ transform-origin: bottom left;
420
+ }
421
+
422
+ .dsq_ctxItem {
423
+ width: 100%;
424
+ gap: 8px;
425
+ padding: 7px 10px;
426
+ border-radius: 8px;
427
+ color: var(--dsq-label);
428
+ font: inherit;
429
+ font-size: 13px;
430
+ line-height: 18px;
431
+ text-align: left;
432
+ white-space: nowrap;
433
+ }
434
+
435
+ .dsq_ctxItem svg {
436
+ flex: none;
437
+ color: var(--dsq-label-2);
438
+ }
439
+
440
+ .dsq_ctxItem:hover,
441
+ .dsq_ctxItem:focus {
442
+ background: var(--dsq-fill-hover);
443
+ }
444
+
445
+ .dsq_ctxItem:focus {
446
+ outline: none;
447
+ }
448
+
449
+ .dsq_ctxItem:focus-visible {
450
+ outline: 2px solid var(--dsq-focus-ring);
451
+ outline-offset: -2px;
452
+ }
453
+
323
454
  /* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
324
455
  .dsq_cardStack {
325
456
  position: absolute;
@@ -373,7 +504,7 @@ ${atomRules}
373
504
  padding: 8px 10px;
374
505
  border: 0.5px solid var(--dsq-line);
375
506
  border-radius: 18px;
376
- background: var(--dsq-neutral-50);
507
+ background: var(--dsq-summary-bg);
377
508
  pointer-events: auto;
378
509
  user-select: none;
379
510
  }
@@ -392,7 +523,9 @@ ${atomRules}
392
523
  position: relative;
393
524
  }
394
525
 
395
- .dsq_summaryTitle {
526
+ /* Card titles (composer summary / touch sheet) share one type scale. */
527
+ .dsq_summaryTitle,
528
+ .dsq_touchSheetTitle {
396
529
  font-size: 14px;
397
530
  font-weight: 500;
398
531
  line-height: 20px;
@@ -405,18 +538,37 @@ ${atomRules}
405
538
  color: var(--dsq-label-3);
406
539
  }
407
540
 
541
+ /* Hover wording is wrong on touch; show one line, not both. */
542
+ .dsq_summaryHintTouch {
543
+ display: none;
544
+ }
545
+
546
+ @media (hover: none), (pointer: coarse) {
547
+ .dsq_summaryHintPointer {
548
+ display: none;
549
+ }
550
+ .dsq_summaryHintTouch {
551
+ display: block;
552
+ }
553
+ }
554
+
555
+ /* The hover panel and the chip preview are the same floating width; keeping it
556
+ in one rule means a width tweak lands on both. */
557
+ .dsq_summaryHover,
558
+ .dsq_chipPreview {
559
+ width: min(320px, calc(100vw - 16px));
560
+ }
561
+
408
562
  .dsq_summaryHover {
409
563
  /* \`display: none\` here is the closed state, so this rule must not take the
410
564
  flex atoms — the open class below turns display back on. */
411
565
  display: none;
412
566
  position: fixed;
413
567
  z-index: 14;
414
- width: min(320px, calc(100vw - 16px));
415
568
  flex-direction: column;
416
- padding: 12px;
417
- /* Facing-side pad is set in JS from placement side; this is the base. */
418
- padding-top: 12px;
419
- padding-bottom: 12px;
569
+ /* No side padding: the visible card must share the summary's left edge. The
570
+ transparent hover bridge is added by place() only on the facing side. */
571
+ padding: 0;
420
572
  background: transparent;
421
573
  pointer-events: auto;
422
574
  opacity: 0;
@@ -432,14 +584,22 @@ ${atomRules}
432
584
  display: flex;
433
585
  }
434
586
 
587
+ /* Keep the closing panel mounted briefly; the list reverses its rise. */
588
+ .dsq_summaryHoverLeaving {
589
+ pointer-events: none;
590
+ }
591
+
592
+ .dsq_summaryHoverLeaving .dsq_summaryList {
593
+ animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
594
+ }
595
+
435
596
  .dsq_summaryHoverInner {
436
597
  position: relative;
437
598
  gap: 4px;
438
599
  padding: 4px;
439
600
  border-radius: 12px;
440
601
  /* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
441
- 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里。 */
442
- box-shadow: var(--dsq-shadow);
602
+ 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
443
603
  }
444
604
 
445
605
  /* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
@@ -457,7 +617,10 @@ ${atomRules}
457
617
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
458
618
  .dsq_toolbar::before,
459
619
  .dsq_commentPop::before,
460
- .dsq_summaryHoverInner::before {
620
+ .dsq_summaryHoverInner::before,
621
+ .dsq_touchBar::before,
622
+ .dsq_chipPreview::before,
623
+ .dsq_ctxMenu::before {
461
624
  background: var(--dsq-bg-raised);
462
625
  backdrop-filter: none;
463
626
  -webkit-backdrop-filter: none;
@@ -477,6 +640,9 @@ ${atomRules}
477
640
 
478
641
  .dsq_summaryItemHead {
479
642
  gap: 6px;
643
+ /* Normal mode's action buttons set the row height; keep it in edit mode too
644
+ so the badge and the label never move when the actions disappear. */
645
+ min-height: 26px;
480
646
  }
481
647
 
482
648
  .dsq_summaryItemBadge {
@@ -516,6 +682,9 @@ ${atomRules}
516
682
 
517
683
  .dsq_summaryItemMain {
518
684
  min-width: 0;
685
+ /* The lead-in lives on the shared wrapper, not on the normal-mode button, so
686
+ edit mode has no wrapper to compensate for. */
687
+ padding-top: 2px;
519
688
  }
520
689
 
521
690
  .dsq_summaryItemButton {
@@ -524,7 +693,9 @@ ${atomRules}
524
693
  text-align: left;
525
694
  font: inherit;
526
695
  border-radius: 8px;
527
- padding: 2px 0 0;
696
+ /* No padding: the 2px lead-in is on .dsq_summaryItemMain, and the UA's
697
+ default button padding would shift the quote sideways and down. */
698
+ padding: 0;
528
699
  }
529
700
 
530
701
  .dsq_annAnchor {
@@ -535,18 +706,36 @@ ${atomRules}
535
706
  pointer-events: none;
536
707
  }
537
708
 
538
- .dsq_summaryItemLabel {
709
+ /* Field labels shared by the hover list and the chip preview. */
710
+ .dsq_summaryItemLabel,
711
+ .dsq_chipPreviewLabel {
539
712
  font-size: 11px;
540
713
  line-height: 16px;
541
714
  color: var(--dsq-label-3);
542
715
  }
543
716
 
544
- .dsq_summaryItemText {
717
+ .dsq_summaryItemLabel {
718
+ margin-top: 8px;
719
+ margin-bottom: 4px;
720
+ }
721
+
722
+ /* The header label shares the class but sits in a centered flex row. */
723
+ .dsq_summaryItemHead .dsq_summaryItemLabel {
724
+ margin: 0;
725
+ }
726
+
727
+ /* Quote/comment body type is shared; only the box behavior differs. */
728
+ .dsq_summaryItemText,
729
+ .dsq_chipPreviewQuote,
730
+ .dsq_chipPreviewComment {
545
731
  font-size: 13px;
546
- line-height: 18px;
547
732
  color: var(--dsq-label);
548
733
  white-space: pre-wrap;
549
734
  overflow-wrap: anywhere;
735
+ }
736
+
737
+ .dsq_summaryItemText {
738
+ line-height: 18px;
550
739
  display: -webkit-box;
551
740
  -webkit-line-clamp: 3;
552
741
  -webkit-box-orient: vertical;
@@ -557,24 +746,22 @@ ${atomRules}
557
746
  color: var(--dsq-label-3);
558
747
  }
559
748
 
560
- /* Inline edit form rendered inside the hover panel item (no extra layer). */
561
- .dsq_annEditText {
562
- font-size: 13px;
563
- line-height: 20px;
564
- color: var(--dsq-label);
565
- white-space: pre-wrap;
566
- overflow-wrap: anywhere;
749
+ /* Inline edit form rendered inside the hover panel item (no extra layer).
750
+ Shares .dsq_summaryItemText with the read view, but shows the full quote
751
+ instead of clamp-reading it. */
752
+ .dsq_summaryItemTextEdit {
753
+ display: block;
567
754
  max-height: 132px;
568
755
  overflow-y: auto;
569
- margin: 2px 0 6px;
756
+ overscroll-behavior: contain;
570
757
  }
571
758
 
572
- .dsq_annEditInput {
759
+ /* Textarea skin shared by the annotation editor and the comment popover; the
760
+ variants below only set min-height, resize and radius. */
761
+ .dsq_annEditInput,
762
+ .dsq_commentInput {
573
763
  width: 100%;
574
- min-height: 72px;
575
- resize: vertical;
576
764
  border: none;
577
- border-radius: 10px;
578
765
  padding: 8px 10px;
579
766
  font: inherit;
580
767
  font-size: 13px;
@@ -584,44 +771,47 @@ ${atomRules}
584
771
  outline: none;
585
772
  }
586
773
 
587
- .dsq_annEditInput::placeholder {
774
+ .dsq_annEditInput::placeholder,
775
+ .dsq_commentInput::placeholder {
588
776
  color: var(--dsq-label-3);
589
777
  }
590
778
 
779
+ .dsq_annEditInput {
780
+ min-height: 72px;
781
+ resize: vertical;
782
+ border-radius: 10px;
783
+ }
784
+
785
+ .dsq_commentActions,
591
786
  .dsq_annEditActions {
592
787
  justify-content: flex-end;
788
+ }
789
+
790
+ .dsq_annEditActions {
593
791
  gap: 8px;
594
792
  margin-top: 4px;
595
793
  }
596
794
 
597
- .dsq_annEditCancel {
598
- color: var(--dsq-label-2);
795
+ /* Footer buttons share type and metrics; the save fill comes from the shared
796
+ accent-action rule above. */
797
+ .dsq_annEditCancel,
798
+ .dsq_annEditSave {
599
799
  font: inherit;
600
800
  font-size: 13px;
601
801
  padding: 4px 12px;
602
802
  border-radius: 10px;
603
803
  }
604
804
 
805
+ .dsq_annEditCancel {
806
+ color: var(--dsq-label-2);
807
+ }
808
+
605
809
  .dsq_annEditCancel:hover {
606
810
  background: var(--dsq-fill-hover);
607
811
  }
608
812
 
609
813
  .dsq_annEditSave {
610
- appearance: none;
611
- border: none;
612
- background: var(--dsq-accent);
613
- color: #fff;
614
- font: inherit;
615
- font-size: 13px;
616
814
  font-weight: 500;
617
- padding: 4px 12px;
618
- border-radius: 10px;
619
- cursor: pointer;
620
- transition: filter 0.12s;
621
- }
622
-
623
- .dsq_annEditSave:hover {
624
- background: var(--dsq-accent-400);
625
815
  }
626
816
 
627
817
  .dsq_annEditSave:disabled {
@@ -640,130 +830,332 @@ ${atomRules}
640
830
  gap: 8px;
641
831
  padding: 8px;
642
832
  border-radius: 16px;
643
- /* 底色与模糊都在材质图层里 */
644
- box-shadow: var(--dsq-shadow);
833
+ /* 底色与模糊都在材质图层里,elevation 在共享规则里 */
645
834
  /* The layer host is pointer-events:none; interactive surfaces opt back in. */
646
835
  pointer-events: auto;
647
836
  }
648
837
 
649
838
  .dsq_commentInput {
650
- width: 100%;
651
839
  min-height: 64px;
652
840
  resize: none;
653
- border: none;
654
841
  border-radius: 8px;
655
- padding: 8px 10px;
656
- font: inherit;
657
- font-size: 13px;
658
- line-height: 20px;
659
- color: var(--dsq-label);
660
- background: var(--dsq-fill-hover);
661
- outline: none;
662
- }
663
-
664
- .dsq_commentInput::placeholder {
665
- color: var(--dsq-label-3);
666
842
  }
667
843
 
668
844
  .dsq_commentActions {
669
- display: flex;
670
- justify-content: flex-end;
671
845
  gap: 6px;
672
846
  }
673
847
 
674
- /* Transcript summary — the same block as the composer's, right-aligned under
675
- the user bubble. One block per message; the hover list carries every
676
- annotation with its selected text and comment. */
677
- .dsq_tSummary {
848
+ /* Preview for a model-written annotation chip. Body-level fixed (the chat
849
+ surface's contain: layout would otherwise capture it); hover and click
850
+ share this one surface, and the button owns the jump. */
851
+ .dsq_chipPreview {
852
+ position: fixed;
853
+ z-index: 50;
854
+ box-sizing: border-box;
855
+ max-height: min(420px, calc(100vh - 32px));
856
+ padding: 10px;
857
+ border-radius: 14px;
858
+ pointer-events: auto;
859
+ overflow: hidden;
860
+ }
861
+
862
+ /* Same rise as the annotation card's hover list; on the content, not on the
863
+ material layer, so backdrop sampling stays put. */
864
+ .dsq_chipPreviewBody {
678
865
  display: flex;
679
- justify-content: flex-end;
680
- margin: 4px 0 8px;
866
+ flex-direction: column;
867
+ gap: 6px;
868
+ animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
681
869
  }
682
870
 
683
- .dsq_tSummary .dsq_summary {
684
- width: auto;
685
- min-width: 220px;
686
- max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
871
+ .dsq_chipPreviewLeaving {
872
+ pointer-events: none;
687
873
  }
688
874
 
689
- /* Replacement user bubble (quote block stripped from display). */
690
- .dsq_userRow {
691
- gap: 6px;
875
+ .dsq_chipPreviewLeaving .dsq_chipPreviewBody {
876
+ animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
692
877
  }
693
878
 
694
- .dsq_userStack {
695
- min-width: 0;
696
- max-width: min(calc(var(--dsq-chat-width) * 0.702), 82%);
879
+ /* Header rows: title on the left, the single action on the right. */
880
+ .dsq_chipPreviewHead,
881
+ .dsq_touchSheetHead {
882
+ justify-content: space-between;
697
883
  gap: 8px;
698
884
  }
699
885
 
700
- .dsq_userBubble {
701
- max-width: 100%;
702
- padding: 10px 16px;
703
- border-radius: 22px;
704
- background: var(--dsq-bubble);
886
+ .dsq_chipPreviewTitle {
887
+ font-size: 13px;
888
+ font-weight: 600;
889
+ line-height: 18px;
705
890
  color: var(--dsq-label);
706
- font-size: var(--dsq-font-size);
707
- line-height: calc(22px + var(--dsq-font-delta));
708
- white-space: pre-wrap;
709
- word-break: break-word;
710
891
  }
711
892
 
712
- /* Durable message images of a replacement user bubble. */
713
- .dsq_userImages {
714
- max-width: 100%;
893
+ .dsq_chipPreviewLabel {
894
+ margin-top: 2px;
715
895
  }
716
896
 
717
- .dsq_userImages img {
718
- display: block;
719
- max-width: 100%;
720
- border-radius: 12px;
897
+ .dsq_chipPreviewQuote,
898
+ .dsq_chipPreviewComment {
899
+ line-height: 19px;
900
+ max-height: 108px;
901
+ overflow-y: auto;
902
+ overscroll-behavior: contain;
721
903
  }
722
904
 
723
- /* Model-written :dsh-annotation{index="N"} chips. */
724
- .dsq_annDirective {
725
- appearance: none;
726
- margin: 0 2px;
727
- padding: 0 6px;
728
- border: 0.5px solid var(--dsq-line);
729
- border-radius: 6px;
730
- background: var(--dsq-fill-hover);
731
- color: var(--dsq-link);
905
+ .dsq_chipPreviewComment {
906
+ color: var(--dsq-label-2);
907
+ }
908
+
909
+ .dsq_chipPreviewCommentEmpty {
910
+ color: var(--dsq-label-3);
911
+ }
912
+
913
+ .dsq_chipPreviewJump {
914
+ flex: none;
915
+ justify-content: center;
916
+ gap: 4px;
917
+ min-height: 28px;
918
+ padding: 0 9px;
919
+ border-radius: 7px;
732
920
  font: inherit;
733
921
  font-size: 12px;
734
- line-height: 18px;
735
- cursor: pointer;
736
- vertical-align: baseline;
922
+ font-weight: 500;
923
+ line-height: 1;
924
+ white-space: nowrap;
737
925
  }
738
926
 
739
- /* Inline annotation mark in message bodies: highlight underline + ordinal badge. */
740
- .dsq_annMark {
741
- --dsq-mark-color: var(--dsq-accent);
742
- --dsq-mark-tint: var(--dsq-accent-tint);
743
- position: relative;
744
- background: var(--dsq-mark-tint);
745
- text-decoration: underline;
746
- text-decoration-style: dashed;
747
- text-decoration-color: var(--dsq-mark-color);
748
- text-decoration-thickness: 2px;
749
- text-underline-offset: 2px;
750
- border-radius: 2px;
751
- scroll-margin-block: 96px;
752
- transition: background-color 0.12s;
927
+ /* Touch selection chrome. Fixed to the visual viewport and mounted on body:
928
+ the OS selection menu owns the space around the selected text, and the soft
929
+ keyboard owns the bottom, so neither content coordinates nor the scroller's
930
+ clipping can place this correctly. Only rendered on coarse pointers. */
931
+ .dsq_touchBar,
932
+ .dsq_touchSheet {
933
+ position: fixed;
934
+ left: 50%;
935
+ right: auto;
936
+ box-sizing: border-box;
937
+ width: min(520px, calc(100vw - 16px));
938
+ transform: translateX(-50%);
939
+ touch-action: manipulation;
940
+ pointer-events: auto;
753
941
  }
754
942
 
755
- /* Ordinal as a superscript corner badge at the END of the marked text, so it
756
- never sits between the reader and the sentence. Only the last fragment of a
757
- multi-fragment mark carries the index, so the number appears exactly once.
758
- Absolute so the badge adds no inline advance width: annotating never reflows
759
- the paragraph or moves following words. Bottom (not top) anchors to the last
760
- fragment: for a mark spanning several lines the containing block is the union
761
- of the first and last fragments, so its bottom edge is the last line. */
762
- .dsq_annMark[data-dsq-ann-index]::after {
763
- content: attr(data-dsq-ann-index);
943
+ .dsq_touchBar {
944
+ z-index: 60;
945
+ gap: 4px;
946
+ padding: 6px;
947
+ border-radius: 16px;
948
+ }
949
+
950
+ .dsq_touchAction {
951
+ flex: 1 1 0;
952
+ min-width: 0;
953
+ min-height: 48px;
954
+ align-items: center;
955
+ justify-content: center;
956
+ gap: 3px;
957
+ padding: 6px 4px;
958
+ border-radius: 10px;
959
+ color: var(--dsq-label);
960
+ font: inherit;
961
+ font-size: 11px;
962
+ line-height: 14px;
963
+ }
964
+
965
+ .dsq_touchAction span {
966
+ max-width: 100%;
967
+ overflow: hidden;
968
+ text-overflow: ellipsis;
969
+ white-space: nowrap;
970
+ }
971
+
972
+ .dsq_touchAction:active {
973
+ background: var(--dsq-fill-hover);
974
+ }
975
+
976
+ .dsq_touchScrim {
977
+ position: fixed;
978
+ inset: 0;
979
+ z-index: 61;
980
+ background: var(--dsq-scrim);
981
+ touch-action: none;
982
+ }
983
+
984
+ .dsq_touchSheet {
985
+ z-index: 62;
986
+ gap: 10px;
987
+ padding: 12px;
988
+ border-radius: 20px;
989
+ background: var(--dsq-bg-raised);
990
+ overflow-y: auto;
991
+ overscroll-behavior: contain;
992
+ }
993
+
994
+ .dsq_touchSheetClose {
995
+ flex: none;
996
+ width: 44px;
997
+ height: 44px;
998
+ margin: -8px -8px -8px 0;
999
+ border-radius: 12px;
1000
+ color: var(--dsq-label-2);
1001
+ }
1002
+
1003
+ .dsq_touchSheetClose:active {
1004
+ background: var(--dsq-fill-hover);
1005
+ }
1006
+
1007
+ /* Keep the selected words visible after the sheet covers the page: on touch the
1008
+ reader has no hover to re-orient, so the sheet carries its own quote. */
1009
+ .dsq_touchQuote {
1010
+ border-left: 2px solid var(--dsq-accent);
1011
+ border-radius: 6px;
1012
+ padding: 6px 8px;
1013
+ background: var(--dsq-fill-hover);
1014
+ color: var(--dsq-label-2);
1015
+ font-size: 12px;
1016
+ line-height: 18px;
1017
+ white-space: pre-wrap;
1018
+ overflow-wrap: anywhere;
1019
+ display: -webkit-box;
1020
+ -webkit-line-clamp: 2;
1021
+ -webkit-box-orient: vertical;
1022
+ overflow: hidden;
1023
+ }
1024
+
1025
+ /* 16px keeps iOS from zooming the page when the field is focused. */
1026
+ .dsq_touchComment {
1027
+ min-height: 88px;
1028
+ font-size: 16px;
1029
+ line-height: 22px;
1030
+ }
1031
+
1032
+ .dsq_touchActions {
1033
+ gap: 8px;
1034
+ }
1035
+
1036
+ .dsq_touchActions .dsq_button {
1037
+ flex: 1;
1038
+ min-height: 44px;
1039
+ justify-content: center;
1040
+ font-size: 14px;
1041
+ }
1042
+
1043
+ .dsq_touchSecondary {
1044
+ background: var(--dsq-fill-hover);
1045
+ }
1046
+
1047
+ .dsq_touchStatus {
1048
+ position: fixed;
1049
+ left: 50%;
1050
+ z-index: 63;
1051
+ transform: translateX(-50%);
1052
+ max-width: calc(100vw - 32px);
1053
+ }
1054
+
1055
+ /* Transcript summary — the same block as the composer's, right-aligned under
1056
+ the user bubble. One block per message; the hover list carries every
1057
+ annotation with its selected text and comment. */
1058
+ .dsq_tSummary {
1059
+ display: flex;
1060
+ justify-content: flex-end;
1061
+ margin: 4px 0 8px;
1062
+ }
1063
+
1064
+ .dsq_tSummary .dsq_summary {
1065
+ width: auto;
1066
+ min-width: 160px;
1067
+ max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
1068
+ /* In the composer the card must clear .dsq_cardStack (12); in the transcript
1069
+ it is plain content and must stay under the floating layer's host (6/8),
1070
+ otherwise a hovered comment popover is painted beneath it. */
1071
+ z-index: 1;
1072
+ }
1073
+
1074
+ /* Replacement user bubble (quote block stripped from display). */
1075
+ .dsq_userRow {
1076
+ gap: 6px;
1077
+ }
1078
+
1079
+ .dsq_userStack {
1080
+ min-width: 0;
1081
+ max-width: min(calc(var(--dsq-chat-width) * 0.702), 82%);
1082
+ gap: 8px;
1083
+ }
1084
+
1085
+ .dsq_userBubble {
1086
+ max-width: 100%;
1087
+ padding: 10px 16px;
1088
+ border-radius: 22px;
1089
+ background: var(--dsq-bubble);
1090
+ color: var(--dsq-label);
1091
+ font-size: var(--dsq-font-size);
1092
+ line-height: calc(22px + var(--dsq-font-delta));
1093
+ white-space: pre-wrap;
1094
+ word-break: break-word;
1095
+ }
1096
+
1097
+ /* Durable message images of a replacement user bubble. */
1098
+ .dsq_userImages {
1099
+ max-width: 100%;
1100
+ }
1101
+
1102
+ .dsq_userImages img {
1103
+ display: block;
1104
+ max-width: 100%;
1105
+ border-radius: 12px;
1106
+ }
1107
+
1108
+ /* Model-written :dsh-annotation{index="N"} chips. */
1109
+ .dsq_annDirective {
1110
+ appearance: none;
1111
+ margin: 0 2px;
1112
+ padding: 0 6px;
1113
+ border: 0.5px solid var(--dsq-line);
1114
+ border-radius: 6px;
1115
+ background: var(--dsq-fill-hover);
1116
+ color: var(--dsq-link);
1117
+ font: inherit;
1118
+ font-size: 12px;
1119
+ line-height: 18px;
1120
+ cursor: pointer;
1121
+ vertical-align: baseline;
1122
+ }
1123
+
1124
+ /* Inline annotation mark in message bodies: highlight underline + ordinal badge. */
1125
+ .dsq_annMark {
1126
+ --dsq-mark-color: var(--dsq-accent);
1127
+ --dsq-mark-tint: var(--dsq-accent-tint);
1128
+ position: relative;
1129
+ background: var(--dsq-mark-tint);
1130
+ text-decoration: underline;
1131
+ text-decoration-style: dashed;
1132
+ text-decoration-color: var(--dsq-mark-color);
1133
+ text-decoration-thickness: 2px;
1134
+ text-underline-offset: 2px;
1135
+ border-radius: 2px;
1136
+ scroll-margin-block: 96px;
1137
+ transition: background-color 0.12s;
1138
+ }
1139
+
1140
+ /* Ordinal as a superscript corner badge at the END of the marked text, so it
1141
+ never sits between the reader and the sentence. Only the last fragment of a
1142
+ multi-fragment mark carries the index, so the number appears exactly once.
1143
+ Absolute so the badge adds no inline advance width: annotating never reflows
1144
+ the paragraph or moves following words. Bottom (not top) anchors to the last
1145
+ fragment: for a mark spanning several lines the containing block is the union
1146
+ of the first and last fragments, so its bottom edge is the last line.
1147
+ BADGE_BOTTOM / BADGE_HEIGHT / the 2px margin in annotate-text.ts mirror the
1148
+ numbers below for collision measurement. */
1149
+ .dsq_annMark[data-dsq-ann-index]::after {
1150
+ content: attr(data-dsq-ann-index);
764
1151
  position: absolute;
765
1152
  bottom: 8px;
766
1153
  left: 100%;
1154
+ /* Above the native selection highlight and the background of a later mark:
1155
+ both otherwise paint over the badge when their ranges meet. */
1156
+ z-index: 2;
1157
+ /* JS shifts colliding badges right/up; the resting position is unchanged. */
1158
+ transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
767
1159
  display: grid;
768
1160
  place-items: center;
769
1161
  min-width: 15px;
@@ -780,13 +1172,270 @@ ${atomRules}
780
1172
  }
781
1173
 
782
1174
  .dsq_annMark:hover {
783
- background: var(--dsq-accent-tint-hover);
1175
+ background: var(--dsq-mark-hover);
784
1176
  }
785
1177
 
786
1178
  .dsq_annMarkFlash {
787
1179
  animation: dsq-ann-flash 1.4s ease-out;
788
1180
  }
789
1181
 
1182
+ /* Fallback jump target when the source mark is no longer in the DOM: flash the
1183
+ message that owns it instead of scrolling nowhere. */
1184
+ .dsq_annMessageFlash {
1185
+ animation: dsq-ann-message-flash 1.4s ease-out;
1186
+ }
1187
+
1188
+ @keyframes dsq-ann-message-flash {
1189
+ 0% {
1190
+ box-shadow: inset 0 0 0 2px var(--dsq-accent);
1191
+ }
1192
+ 100% {
1193
+ box-shadow: inset 0 0 0 2px transparent;
1194
+ }
1195
+ }
1196
+
1197
+ /* Keyboard focus is a first-class state for the toolbar, summary and panel. */
1198
+ .dsq_summary:focus-visible,
1199
+ .dsq_button:focus-visible,
1200
+ .dsq_touchAction:focus-visible,
1201
+ .dsq_touchSheetClose:focus-visible,
1202
+ .dsq_chipPreviewJump:focus-visible,
1203
+ .dsq_annMark:focus-visible,
1204
+ .dsq_summaryItemAction:focus-visible,
1205
+ .dsq_summaryItemButton:focus-visible,
1206
+ .dsq_cardClose:focus-visible,
1207
+ .dsq_annEditCancel:focus-visible,
1208
+ .dsq_annEditSave:focus-visible {
1209
+ outline: 2px solid var(--dsq-focus-ring);
1210
+ outline-offset: 2px;
1211
+ }
1212
+
1213
+ .dsq_commentInput:focus-visible,
1214
+ .dsq_annEditInput:focus-visible {
1215
+ /* The host's button fill is monochrome (black on light, white on dark); a
1216
+ half-transparent accent ring reads as focus without looking like a border. */
1217
+ outline: 2px solid var(--dsq-focus-ring-soft);
1218
+ outline-offset: 1px;
1219
+ }
1220
+
1221
+ /* Touch: 44px minimum targets. Icon-only corners keep their visual size and
1222
+ grow an invisible hit area; toolbar actions grow in place. */
1223
+ @media (hover: none), (pointer: coarse) {
1224
+ .dsq_button,
1225
+ .dsq_annEditCancel,
1226
+ .dsq_annEditSave {
1227
+ min-height: 44px;
1228
+ }
1229
+ .dsq_summaryItemAction {
1230
+ width: 44px;
1231
+ height: 44px;
1232
+ }
1233
+ .dsq_cardClose::after {
1234
+ content: "";
1235
+ position: absolute;
1236
+ inset: -12px;
1237
+ }
1238
+ .dsq_chipPreviewJump {
1239
+ min-height: 44px;
1240
+ }
1241
+ .dsq_segOption {
1242
+ min-height: 44px;
1243
+ }
1244
+ }
1245
+
1246
+ /* Settings row (plugin page): an inline segmented control. The page owns the
1247
+ write, so there is no save button — each pick is committed immediately. */
1248
+ .dsq_settings {
1249
+ display: flex;
1250
+ flex-direction: column;
1251
+ gap: 10px;
1252
+ padding: 2px 0;
1253
+ }
1254
+
1255
+ .dsq_settingsSection {
1256
+ display: flex;
1257
+ flex-direction: column;
1258
+ gap: 10px;
1259
+ }
1260
+
1261
+ .dsq_settingsCopy {
1262
+ display: flex;
1263
+ flex-direction: column;
1264
+ gap: 3px;
1265
+ }
1266
+
1267
+ .dsq_settingsLabel {
1268
+ color: var(--dsq-label);
1269
+ font-size: 13px;
1270
+ font-weight: 500;
1271
+ }
1272
+
1273
+ .dsq_settingsHint {
1274
+ color: var(--dsq-label-3);
1275
+ font-size: 12px;
1276
+ line-height: 1.5;
1277
+ }
1278
+
1279
+ /* Shortcut reference under the switch: the two commands and the keycaps the
1280
+ Host currently has in effect, so a rebind shows up here without a reload. */
1281
+ .dsq_settingsShortcuts {
1282
+ display: flex;
1283
+ flex-direction: column;
1284
+ gap: 6px;
1285
+ margin: 0;
1286
+ padding: 0;
1287
+ list-style: none;
1288
+ }
1289
+
1290
+ .dsq_settingsShortcutRow {
1291
+ display: flex;
1292
+ align-items: center;
1293
+ justify-content: space-between;
1294
+ gap: 12px;
1295
+ min-height: 22px;
1296
+ }
1297
+
1298
+ .dsq_settingsShortcutName {
1299
+ min-width: 0;
1300
+ color: var(--dsq-label-2);
1301
+ font-size: 12px;
1302
+ }
1303
+
1304
+ .dsq_settingsShortcutKeys {
1305
+ flex: none;
1306
+ color: var(--dsq-label-3);
1307
+ font-size: 12px;
1308
+ }
1309
+
1310
+ .dsq_keycap {
1311
+ display: inline-block;
1312
+ min-width: 18px;
1313
+ padding: 1px 6px;
1314
+ border: 1px solid var(--dsq-line);
1315
+ border-radius: 5px;
1316
+ background: var(--dsq-fill-hover);
1317
+ color: var(--dsq-label-2);
1318
+ font: inherit;
1319
+ font-size: 11px;
1320
+ line-height: 18px;
1321
+ text-align: center;
1322
+ white-space: nowrap;
1323
+ }
1324
+
1325
+ .dsq_seg {
1326
+ display: inline-flex;
1327
+ align-self: flex-start;
1328
+ gap: 2px;
1329
+ padding: 2px;
1330
+ border-radius: 8px;
1331
+ background: var(--dsq-fill-hover);
1332
+ }
1333
+
1334
+ .dsq_segOption {
1335
+ position: relative;
1336
+ display: inline-flex;
1337
+ align-items: center;
1338
+ justify-content: center;
1339
+ height: 28px;
1340
+ padding: 0 12px;
1341
+ border-radius: 6px;
1342
+ color: var(--dsq-label-2);
1343
+ font-size: 13px;
1344
+ cursor: pointer;
1345
+ transition: background-color 0.12s ease, color 0.12s ease;
1346
+ }
1347
+
1348
+ .dsq_segOption:hover {
1349
+ color: var(--dsq-label);
1350
+ }
1351
+
1352
+ .dsq_segOption[data-active="true"] {
1353
+ background: var(--dsq-bg-raised);
1354
+ box-shadow: 0 0 0 0.5px var(--dsq-line);
1355
+ color: var(--dsq-label);
1356
+ }
1357
+
1358
+ .dsq_segOption:has(:focus-visible) {
1359
+ outline: 2px solid var(--dsq-focus-ring-soft);
1360
+ outline-offset: 1px;
1361
+ }
1362
+
1363
+ .dsq_settingsDivider {
1364
+ height: 1px;
1365
+ background: var(--dsq-line);
1366
+ }
1367
+
1368
+ /* The whole row is the target: label text, hint, and the switch itself. */
1369
+ .dsq_settingsSwitchRow {
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: space-between;
1373
+ gap: 16px;
1374
+ cursor: pointer;
1375
+ }
1376
+
1377
+ .dsq_settingsSwitchRow .dsq_settingsCopy {
1378
+ flex: 1;
1379
+ min-width: 0;
1380
+ }
1381
+
1382
+ /* Exactly the Host switch metrics: 36×20 track, 16px thumb, 2px inset, and
1383
+ the same on/off tokens. The thumb only animates its transform, like the
1384
+ product control; the track changes color in one step. */
1385
+ .dsq_switch {
1386
+ box-sizing: border-box;
1387
+ position: relative;
1388
+ flex: none;
1389
+ width: 36px;
1390
+ height: 20px;
1391
+ padding: 2px;
1392
+ border: 0;
1393
+ border-radius: 999px;
1394
+ corner-shape: round;
1395
+ background: var(--dsq-switch-off);
1396
+ cursor: pointer;
1397
+ }
1398
+
1399
+ .dsq_switch[data-on='true'] {
1400
+ background: var(--dsq-switch-on);
1401
+ }
1402
+
1403
+ .dsq_switchTrack {
1404
+ position: absolute;
1405
+ top: 2px;
1406
+ left: 2px;
1407
+ width: 16px;
1408
+ height: 16px;
1409
+ border-radius: 50%;
1410
+ corner-shape: round;
1411
+ background: var(--dsq-switch-thumb-off);
1412
+ transition: transform 0.12s ease;
1413
+ }
1414
+
1415
+ .dsq_switch[data-on='true'] .dsq_switchTrack {
1416
+ background: var(--dsq-switch-thumb-on);
1417
+ transform: translateX(16px);
1418
+ }
1419
+
1420
+ .dsq_switch:has(:focus-visible) {
1421
+ outline: 2px solid var(--dsq-focus-ring-soft);
1422
+ outline-offset: 2px;
1423
+ }
1424
+
1425
+ /* Visually hidden but still focusable: the native radio/checkbox carries the
1426
+ semantics and keyboard behavior, the label carries the paint. */
1427
+ .dsq_srOnly {
1428
+ position: absolute;
1429
+ width: 1px;
1430
+ height: 1px;
1431
+ margin: -1px;
1432
+ padding: 0;
1433
+ overflow: hidden;
1434
+ clip: rect(0 0 0 0);
1435
+ white-space: nowrap;
1436
+ border: 0;
1437
+ }
1438
+
790
1439
  /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
791
1440
  into product-owned DOM. */
792
1441
 
@@ -794,10 +1443,12 @@ ${atomRules}
794
1443
  0% {
795
1444
  background: var(--dsq-mark-color);
796
1445
  color: #fff;
1446
+ box-shadow: 0 0 0 3px var(--dsq-mark-tint);
797
1447
  }
798
1448
  100% {
799
1449
  background: var(--dsq-mark-tint);
800
1450
  color: inherit;
1451
+ box-shadow: 0 0 0 0 transparent;
801
1452
  }
802
1453
  }
803
1454
 
@@ -805,9 +1456,10 @@ ${atomRules}
805
1456
  --dsq-* alias follows it. Only values we own ourselves need restating. */
806
1457
  body[data-ds-dark-theme] .dsq_annMark {
807
1458
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
1459
+ --dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
808
1460
  }
809
1461
 
810
- /* Reserve room for the floating quote cards inside the composer card, so the
1462
+ /* Reserve room for the floating annotation summary inside the composer card, so the
811
1463
  attachment rail and the editor flow below them instead of being covered. */
812
1464
  .dsq_cardPad {
813
1465
  padding-top: var(--dsq-quote-pad, 88px) !important;
@@ -830,6 +1482,8 @@ const styles = {
830
1482
  buttonAddComment: "dsq_buttonAddComment",
831
1483
  divider: "dsq_divider",
832
1484
  status: "dsq_status",
1485
+ ctxMenu: "dsq_ctxMenu",
1486
+ ctxItem: "dsq_ctxItem",
833
1487
  cardStack: "dsq_cardStack",
834
1488
  cardClose: "dsq_cardClose",
835
1489
  cardPad: "dsq_cardPad",
@@ -838,7 +1492,10 @@ const styles = {
838
1492
  summaryBody: "dsq_summaryBody",
839
1493
  summaryTitle: "dsq_summaryTitle",
840
1494
  summaryHint: "dsq_summaryHint",
1495
+ summaryHintPointer: "dsq_summaryHintPointer",
1496
+ summaryHintTouch: "dsq_summaryHintTouch",
841
1497
  summaryHover: "dsq_summaryHover",
1498
+ summaryHoverLeaving: "dsq_summaryHoverLeaving",
842
1499
  summaryItem: "dsq_summaryItem",
843
1500
  summaryItemLabel: "dsq_summaryItemLabel",
844
1501
  summaryItemText: "dsq_summaryItemText",
@@ -846,7 +1503,7 @@ const styles = {
846
1503
  summaryItemMain: "dsq_summaryItemMain",
847
1504
  summaryItemHead: "dsq_summaryItemHead",
848
1505
  summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
849
- annEditText: "dsq_annEditText",
1506
+ summaryItemTextEdit: "dsq_summaryItemTextEdit",
850
1507
  annEditInput: "dsq_annEditInput",
851
1508
  annEditActions: "dsq_annEditActions",
852
1509
  annEditCancel: "dsq_annEditCancel",
@@ -861,23 +1518,110 @@ const styles = {
861
1518
  annAnchor: "dsq_annAnchor",
862
1519
  annMark: "dsq_annMark",
863
1520
  annMarkFlash: "dsq_annMarkFlash",
1521
+ annMessageFlash: "dsq_annMessageFlash",
864
1522
  commentPop: "dsq_commentPop",
865
1523
  commentInput: "dsq_commentInput",
866
1524
  commentActions: "dsq_commentActions",
1525
+ chipPreview: "dsq_chipPreview",
1526
+ chipPreviewBody: "dsq_chipPreviewBody",
1527
+ chipPreviewLeaving: "dsq_chipPreviewLeaving",
1528
+ chipPreviewHead: "dsq_chipPreviewHead",
1529
+ chipPreviewTitle: "dsq_chipPreviewTitle",
1530
+ chipPreviewLabel: "dsq_chipPreviewLabel",
1531
+ chipPreviewQuote: "dsq_chipPreviewQuote",
1532
+ chipPreviewComment: "dsq_chipPreviewComment",
1533
+ chipPreviewCommentEmpty: "dsq_chipPreviewCommentEmpty",
1534
+ chipPreviewJump: "dsq_chipPreviewJump",
1535
+ touchBar: "dsq_touchBar",
1536
+ touchAction: "dsq_touchAction",
1537
+ touchScrim: "dsq_touchScrim",
1538
+ touchSheet: "dsq_touchSheet",
1539
+ touchSheetHead: "dsq_touchSheetHead",
1540
+ touchSheetTitle: "dsq_touchSheetTitle",
1541
+ touchSheetClose: "dsq_touchSheetClose",
1542
+ touchQuote: "dsq_touchQuote",
1543
+ touchComment: "dsq_touchComment",
1544
+ touchActions: "dsq_touchActions",
1545
+ touchSecondary: "dsq_touchSecondary",
1546
+ touchStatus: "dsq_touchStatus",
867
1547
  tSummary: "dsq_tSummary",
868
1548
  userRow: "dsq_userRow",
869
1549
  userStack: "dsq_userStack",
870
1550
  userImages: "dsq_userImages",
871
- userBubble: "dsq_userBubble"
1551
+ userBubble: "dsq_userBubble",
1552
+ settings: "dsq_settings",
1553
+ settingsSection: "dsq_settingsSection",
1554
+ settingsCopy: "dsq_settingsCopy",
1555
+ settingsLabel: "dsq_settingsLabel",
1556
+ settingsHint: "dsq_settingsHint",
1557
+ settingsShortcuts: "dsq_settingsShortcuts",
1558
+ settingsShortcutRow: "dsq_settingsShortcutRow",
1559
+ settingsShortcutName: "dsq_settingsShortcutName",
1560
+ settingsShortcutKeys: "dsq_settingsShortcutKeys",
1561
+ keycap: "dsq_keycap",
1562
+ settingsDivider: "dsq_settingsDivider",
1563
+ settingsSwitchRow: "dsq_settingsSwitchRow",
1564
+ switch: "dsq_switch",
1565
+ switchTrack: "dsq_switchTrack",
1566
+ seg: "dsq_seg",
1567
+ segOption: "dsq_segOption",
1568
+ srOnly: "dsq_srOnly"
872
1569
  };
873
1570
  //#endregion
1571
+ //#region src/client/dom/badge-layout.ts
1572
+ const STEP_X = 28;
1573
+ const STEP_Y = 18;
1574
+ /** Rest, then two steps right; after that the stack grows upward. */
1575
+ const MAX_COLUMNS = 3;
1576
+ const MAX_ROWS = 24;
1577
+ function overlaps(a, b) {
1578
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1579
+ }
1580
+ function fits(anchor, dx, dy, bounds) {
1581
+ if (dx > 0 && bounds.right !== void 0 && anchor.x + dx + anchor.width > bounds.right) return false;
1582
+ if (dy < 0 && bounds.top !== void 0 && anchor.y + dy < bounds.top) return false;
1583
+ return true;
1584
+ }
1585
+ /**
1586
+ * Greedy placement in reading order. Each badge first tries its resting spot,
1587
+ * then shifts right a couple of badge widths, and finally stacks upward. The
1588
+ * resting spot is always returned when no candidate fits, so a badge never
1589
+ * disappears because of an edge.
1590
+ */
1591
+ function placeBadges(anchors, bounds = {}) {
1592
+ const placed = [];
1593
+ return anchors.map((anchor) => {
1594
+ for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
1595
+ const dx = column * STEP_X;
1596
+ const dy = -row * STEP_Y;
1597
+ if (!fits(anchor, dx, dy, bounds)) continue;
1598
+ const candidate = {
1599
+ ...anchor,
1600
+ x: anchor.x + dx,
1601
+ y: anchor.y + dy
1602
+ };
1603
+ if (placed.some((box) => overlaps(candidate, box))) continue;
1604
+ placed.push(candidate);
1605
+ return {
1606
+ dx,
1607
+ dy
1608
+ };
1609
+ }
1610
+ placed.push(anchor);
1611
+ return {
1612
+ dx: 0,
1613
+ dy: 0
1614
+ };
1615
+ });
1616
+ }
1617
+ //#endregion
874
1618
  //#region src/client/dom/message-text.ts
875
1619
  /**
876
1620
  * Message-text geometry: locate a selection inside the transcript and express
877
1621
  * it as stable character offsets over the message's own text.
878
1622
  *
879
1623
  * Offsets are computed over "clean text": the flow item's text nodes with the
880
- * plugin's own chrome (summary cards, directive chips, anchors) skipped. Marks
1624
+ * plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
881
1625
  * are transparent — their text still counts — so an offset captured before a
882
1626
  * mark exists stays valid after decoration.
883
1627
  */
@@ -1009,6 +1753,9 @@ function cleanTextOf(item) {
1009
1753
  const MARK_ATTR = "data-dsq-ann-mark";
1010
1754
  const INDEX_ATTR = "data-dsq-ann-index";
1011
1755
  const FLASH_MS = 1500;
1756
+ /** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
1757
+ const BADGE_BOTTOM = 8;
1758
+ const BADGE_MARGIN = 2;
1012
1759
  const jobs = /* @__PURE__ */ new Map();
1013
1760
  let pendingIds = /* @__PURE__ */ new Set();
1014
1761
  function attrValue(value) {
@@ -1085,16 +1832,15 @@ function decorateBySearch(item, job) {
1085
1832
  const full = cleanTextOf(item);
1086
1833
  const needle = job.text.trim();
1087
1834
  if (needle.length < 2) return false;
1088
- const patterns = [needle, needle.split(/\s+/).map(escapeRegExp).join("\\s*")];
1089
- for (const pattern of patterns) {
1090
- let at = full.indexOf(pattern);
1091
- while (at >= 0) {
1092
- const located = cleanTextRange(item, at, at + pattern.length);
1093
- if (located) {
1094
- const pieces = overlappingEntries(located.entries, located.from, located.to);
1095
- if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
1096
- }
1097
- at = full.indexOf(pattern, at + 1);
1835
+ const patterns = [new RegExp(escapeRegExp(needle), "g"), new RegExp(needle.split(/\s+/).map(escapeRegExp).join("\\s*"), "g")];
1836
+ for (const pattern of patterns) for (const match of full.matchAll(pattern)) {
1837
+ const at = match.index ?? 0;
1838
+ const length = match[0].length;
1839
+ if (length === 0) continue;
1840
+ const located = cleanTextRange(item, at, at + length);
1841
+ if (located) {
1842
+ const pieces = overlappingEntries(located.entries, located.from, located.to);
1843
+ if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
1098
1844
  }
1099
1845
  }
1100
1846
  return false;
@@ -1106,6 +1852,60 @@ function decorateJob(item, job) {
1106
1852
  if (decorateAtOffsets(item, job)) return true;
1107
1853
  return decorateBySearch(item, job);
1108
1854
  }
1855
+ /**
1856
+ * Existing marks are kept, but their ordinal is owned by the job: removing an
1857
+ * earlier annotation renumbers the rest without re-wrapping the text. Only the
1858
+ * final fragment carries the badge.
1859
+ */
1860
+ function refreshMarkIndex(item, job) {
1861
+ const marks = Array.from(item.querySelectorAll(`[${MARK_ATTR}="${attrValue(job.markId)}"]`));
1862
+ if (marks.length === 0) return false;
1863
+ const last = marks[marks.length - 1];
1864
+ const next = String(job.index);
1865
+ if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
1866
+ return true;
1867
+ }
1868
+ /**
1869
+ * Resolve overlapping ordinal badges. Two annotations can end at the same
1870
+ * offset (nested ranges), which puts their pseudo-element badges on the same
1871
+ * pixel; nearby ends can also touch. Measure every last fragment, then write
1872
+ * the offset back as variables inherited by `::after`. The badge's transform
1873
+ * never participates in text layout.
1874
+ */
1875
+ function layoutBadges(scope) {
1876
+ const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
1877
+ for (const mark of marks) {
1878
+ mark.style.removeProperty("--dsq-ann-badge-x");
1879
+ mark.style.removeProperty("--dsq-ann-badge-y");
1880
+ }
1881
+ if (marks.length < 2) return;
1882
+ const scroller = marks[0]?.closest("[data-conversation-scroll]");
1883
+ const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
1884
+ const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
1885
+ const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
1886
+ const anchors = marks.map((mark) => {
1887
+ const rect = mark.getBoundingClientRect();
1888
+ return {
1889
+ mark,
1890
+ index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
1891
+ x: rect.right + BADGE_MARGIN,
1892
+ y: rect.bottom - BADGE_BOTTOM - 15,
1893
+ width: 24,
1894
+ height: 15,
1895
+ visible: rect.width > 0 && rect.height > 0
1896
+ };
1897
+ }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
1898
+ if (anchors.length < 2) return;
1899
+ const offsets = placeBadges(anchors, {
1900
+ right: rightEdge - 4,
1901
+ top: topEdge + 4
1902
+ });
1903
+ anchors.forEach((anchor, i) => {
1904
+ const { dx, dy } = offsets[i];
1905
+ if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
1906
+ if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
1907
+ });
1908
+ }
1109
1909
  /** Apply every registered job whose message is currently rendered. */
1110
1910
  function applyAnnotationMarks(root) {
1111
1911
  if (jobs.size === 0) return;
@@ -1113,12 +1913,18 @@ function applyAnnotationMarks(root) {
1113
1913
  if (!scope) return;
1114
1914
  for (const job of [...jobs.values()]) {
1115
1915
  const items = flowItems(job.messageId, scope);
1916
+ let decorated = false;
1116
1917
  for (const item of items) {
1117
- if (item.querySelector(`[${MARK_ATTR}="${attrValue(job.markId)}"]`)) break;
1918
+ if (refreshMarkIndex(item, job)) {
1919
+ decorated = true;
1920
+ continue;
1921
+ }
1922
+ if (decorated) continue;
1118
1923
  if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
1119
- if (decorateJob(item, job)) break;
1924
+ if (decorateJob(item, job)) decorated = true;
1120
1925
  }
1121
1926
  }
1927
+ layoutBadges(scope);
1122
1928
  }
1123
1929
  /** Insert or refresh jobs; identical jobs are no-ops. */
1124
1930
  function upsertAnnotationJobs(next) {
@@ -1172,6 +1978,7 @@ function watchAnnotationMarks(root) {
1172
1978
  applyAnnotationMarks(el);
1173
1979
  let frame = 0;
1174
1980
  const schedule = () => {
1981
+ if (jobs.size === 0) return;
1175
1982
  if (frame !== 0) return;
1176
1983
  frame = window.requestAnimationFrame(() => {
1177
1984
  frame = 0;
@@ -1183,8 +1990,11 @@ function watchAnnotationMarks(root) {
1183
1990
  childList: true,
1184
1991
  subtree: true
1185
1992
  });
1993
+ const onResize = () => schedule();
1994
+ window.addEventListener("resize", onResize);
1186
1995
  return () => {
1187
1996
  observer.disconnect();
1997
+ window.removeEventListener("resize", onResize);
1188
1998
  if (frame !== 0) window.cancelAnimationFrame(frame);
1189
1999
  };
1190
2000
  }
@@ -1390,43 +2200,52 @@ function composeAnnotatedMessage(draft, annotations) {
1390
2200
  const rest = stripDraftMarker(draft).replace(/^\s+/, "");
1391
2201
  if (annotations.length === 0) return rest;
1392
2202
  const payload = annotations.map(toWireItem);
2203
+ const json = JSON.stringify(payload).replace(/</g, "\\u003c");
1393
2204
  const block = [
1394
2205
  PROTOCOL_HEADER,
1395
2206
  "",
1396
2207
  ANNOTATIONS_BLOCK_OPEN,
1397
- JSON.stringify(payload),
2208
+ json,
1398
2209
  ANNOTATIONS_BLOCK_CLOSE
1399
2210
  ].join("\n");
1400
2211
  return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
1401
2212
  }
1402
2213
  /** Remove `# Response annotations:` instructional prose before the JSON block. */
1403
2214
  function stripProtocolHeader(prefix) {
1404
- if (prefix.indexOf("# Response annotations:") >= 0) return "";
2215
+ const marker = prefix.indexOf("# Response annotations:");
2216
+ if (marker >= 0) return prefix.slice(0, marker).replace(/\s+$/, "");
1405
2217
  return prefix.replace(/\s+$/, "");
1406
2218
  }
1407
2219
  function blockParts(text) {
1408
2220
  const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1409
2221
  if (open < 0) return null;
1410
- const close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, open + 22);
1411
- if (close < 0) return null;
2222
+ const payloadStart = open + 22;
2223
+ const firstClose = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, payloadStart);
2224
+ let close = firstClose;
2225
+ while (close >= 0) {
2226
+ const json = text.slice(payloadStart, close).trim();
2227
+ try {
2228
+ return {
2229
+ before: stripProtocolHeader(text.slice(0, open)),
2230
+ after: text.slice(close + 23).replace(/^\s+/, ""),
2231
+ parsed: JSON.parse(json)
2232
+ };
2233
+ } catch {
2234
+ close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, close + 23);
2235
+ }
2236
+ }
2237
+ if (firstClose < 0) return null;
1412
2238
  return {
1413
2239
  before: stripProtocolHeader(text.slice(0, open)),
1414
- after: text.slice(close + 23).replace(/^\s+/, ""),
1415
- json: text.slice(open + 22, close).trim()
2240
+ after: text.slice(firstClose + 23).replace(/^\s+/, "")
1416
2241
  };
1417
2242
  }
1418
2243
  function joinedRest(parts) {
1419
2244
  return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
1420
2245
  }
1421
- /** Wire payload as an array; null when the block is not valid JSON. */
2246
+ /** Wire payload as an array; null when the block is not an array. */
1422
2247
  function parseWire(parts) {
1423
- let parsed;
1424
- try {
1425
- parsed = JSON.parse(parts.json);
1426
- } catch {
1427
- return null;
1428
- }
1429
- return Array.isArray(parsed) ? parsed : null;
2248
+ return Array.isArray(parts.parsed) ? parts.parsed : null;
1430
2249
  }
1431
2250
  /** Every source an item carries; falls back to the selected text itself. */
1432
2251
  function sourcesOf(record, body) {
@@ -1453,6 +2272,9 @@ function wireBody(entry) {
1453
2272
  /**
1454
2273
  * Read plugin annotations out of a durable message. Tolerates the JSON block
1455
2274
  * and strips it from `rest` so the bubble can show only the user's question.
2275
+ * A complete block with a malformed or non-array payload yields no
2276
+ * annotations but is still stripped — the transcript never shows protocol
2277
+ * text, valid or not.
1456
2278
  */
1457
2279
  function scanAnnotatedMessage(text) {
1458
2280
  const parts = blockParts(text);
@@ -1460,12 +2282,12 @@ function scanAnnotatedMessage(text) {
1460
2282
  annotations: [],
1461
2283
  rest: text
1462
2284
  };
2285
+ const rest = joinedRest(parts);
1463
2286
  const parsed = parseWire(parts);
1464
2287
  if (parsed === null) return {
1465
2288
  annotations: [],
1466
- rest: text.replace(/^\s+/, "")
2289
+ rest
1467
2290
  };
1468
- const rest = joinedRest(parts);
1469
2291
  const annotations = [];
1470
2292
  for (const entry of parsed) {
1471
2293
  const item = wireBody(entry);
@@ -1517,13 +2339,6 @@ function readComposedDraft(text) {
1517
2339
  }
1518
2340
  //#endregion
1519
2341
  //#region src/client/runtime.ts
1520
- /**
1521
- * Shared runtime handles across slot scopes.
1522
- *
1523
- * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1524
- * panel is session-scoped, so both sides read one partition keyed by the
1525
- * session the host minted for the active composer.
1526
- */
1527
2342
  const runtime = {};
1528
2343
  function setRuntimeSession(sessionId) {
1529
2344
  if (sessionId) runtime.sessionId = sessionId;
@@ -1535,15 +2350,15 @@ const EMPTY$1 = [];
1535
2350
  /** Bucket for annotations added before a session id is known (never persisted). */
1536
2351
  const MEMORY_KEY = "pending";
1537
2352
  const bySession = /* @__PURE__ */ new Map();
1538
- const listeners = /* @__PURE__ */ new Set();
2353
+ const listeners$1 = /* @__PURE__ */ new Set();
1539
2354
  let persistence = null;
1540
2355
  function notify() {
1541
- for (const listener of [...listeners]) listener();
2356
+ for (const listener of [...listeners$1]) listener();
1542
2357
  }
1543
2358
  function subscribeAnnotations(listener) {
1544
- listeners.add(listener);
2359
+ listeners$1.add(listener);
1545
2360
  return () => {
1546
- listeners.delete(listener);
2361
+ listeners$1.delete(listener);
1547
2362
  };
1548
2363
  }
1549
2364
  /**
@@ -1686,18 +2501,63 @@ function adoptPersistedAnnotations(sessionId) {
1686
2501
  if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
1687
2502
  notify();
1688
2503
  }
2504
+ /** Same ids in the same order, with the only mutable field (comment) equal. */
2505
+ function sameAnnotations(a, b) {
2506
+ return a.length === b.length && a.every((item, i) => {
2507
+ const other = b[i];
2508
+ return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2509
+ });
2510
+ }
2511
+ /**
2512
+ * Re-read a session after another tab wrote its storage key.
2513
+ *
2514
+ * Storage is the shared source of truth: every local mutation persists
2515
+ * synchronously, so this tab's memory can only be stale relative to it. The
2516
+ * stored array replaces the local one (last writer wins) — merging would
2517
+ * resurrect annotations the other tab already sent and cleared.
2518
+ *
2519
+ * `null` is the `localStorage.clear()` event: reload every partition this tab
2520
+ * holds. Sessions that are neither cached nor active are ignored; binding the
2521
+ * session later reads storage anyway.
2522
+ */
2523
+ function adoptStorageAnnotations(sessionId) {
2524
+ if (!persistence) return;
2525
+ if (sessionId === null) {
2526
+ let changed = false;
2527
+ for (const key of [...bySession.keys()]) {
2528
+ if (key === MEMORY_KEY) continue;
2529
+ const stored = persistence.load(key);
2530
+ const current = bySession.get(key);
2531
+ if (stored.length === 0) {
2532
+ bySession.delete(key);
2533
+ changed = true;
2534
+ } else if (current === void 0 || !sameAnnotations(stored, current)) {
2535
+ bySession.set(key, [...stored]);
2536
+ changed = true;
2537
+ }
2538
+ }
2539
+ if (changed) notify();
2540
+ return;
2541
+ }
2542
+ if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2543
+ const stored = persistence.load(sessionId);
2544
+ if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2545
+ if (stored.length === 0) bySession.delete(sessionId);
2546
+ else bySession.set(sessionId, [...stored]);
2547
+ notify();
2548
+ }
1689
2549
  function annotationTitle(annotation) {
1690
2550
  return previewTitle(annotation.text);
1691
2551
  }
1692
2552
  //#endregion
1693
2553
  //#region src/client/dom/popoverPlacement.ts
1694
- const GAP = 8;
2554
+ const GAP$2 = 8;
1695
2555
  const MARGIN = 8;
1696
2556
  /**
1697
2557
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
1698
2558
  * lacks room. Keeps the panel inside `bounds` (the viewport by default).
1699
2559
  */
1700
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
2560
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
1701
2561
  const box = bounds ?? {
1702
2562
  top: 0,
1703
2563
  left: 0,
@@ -1784,6 +2644,7 @@ const zh = {
1784
2644
  "common.selectedText": "选中的文本",
1785
2645
  "common.commentPlaceholder": "添加可选评论…",
1786
2646
  "common.cancel": "取消",
2647
+ "common.close": "关闭",
1787
2648
  "common.save": "保存",
1788
2649
  "toolbar.aria": "划词操作",
1789
2650
  "toolbar.commentHint": "添加批注…",
@@ -1794,21 +2655,42 @@ const zh = {
1794
2655
  "toolbar.commentDialogAria": "添加可选评论",
1795
2656
  "toolbar.skipComment": "暂不评论",
1796
2657
  "toolbar.addComment": "添加评论",
2658
+ "menu.aria": "划词菜单",
2659
+ "menu.shortcutLabel": "打开划词菜单",
2660
+ "menu.addShortcutLabel": "将选中文字添加到任务",
2661
+ "menu.noSelection": "先在对话里选中一段文字",
2662
+ "menu.busy": "先完成当前批注",
1797
2663
  "summary.hint": "悬停查看批注",
2664
+ "summary.hintTouch": "点按查看批注",
1798
2665
  "summary.dialogAria": "{count} 条划词批注详情",
1799
2666
  "summary.edit": "编辑批注 {index}",
1800
2667
  "summary.remove": "移除批注 {index}",
1801
2668
  "summary.userComment": "用户评论",
1802
2669
  "summary.noComment": "未添加评论",
1803
- "summary.aria.other": "{count} 条划词批注。悬停查看详情。",
2670
+ "summary.aria.other": "{count} 条划词批注。展开查看详情。",
1804
2671
  "summary.title.other": "{count} 条批注",
1805
2672
  "summary.removeAll": "移除全部划词批注",
1806
- "chip.annotation": "批注 {index}"
2673
+ "chip.annotation": "批注 {index}",
2674
+ "chip.previewAria": "批注预览",
2675
+ "chip.jump": "原文",
2676
+ "chip.jumpAria": "跳转到原文",
2677
+ "settings.trigger": "划词入口",
2678
+ "settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
2679
+ "settings.trigger.auto": "自动浮出",
2680
+ "settings.trigger.contextmenu": "右键菜单",
2681
+ "settings.trigger.both": "两者都开",
2682
+ "settings.shortcut": "快捷键",
2683
+ "settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
2684
+ "settings.shortcutListAria": "当前快捷键",
2685
+ "settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
2686
+ "settings.shortcutUnbound": "未绑定",
2687
+ "settings.shortcutConflict": "与其它快捷键冲突"
1807
2688
  };
1808
2689
  const en = {
1809
2690
  "common.selectedText": "Selected text",
1810
2691
  "common.commentPlaceholder": "Add an optional comment…",
1811
2692
  "common.cancel": "Cancel",
2693
+ "common.close": "Close",
1812
2694
  "common.save": "Save",
1813
2695
  "toolbar.aria": "Selection actions",
1814
2696
  "toolbar.commentHint": "Add annotation…",
@@ -1819,18 +2701,38 @@ const en = {
1819
2701
  "toolbar.commentDialogAria": "Add an optional comment",
1820
2702
  "toolbar.skipComment": "Skip comment",
1821
2703
  "toolbar.addComment": "Add comment",
2704
+ "menu.aria": "Selection menu",
2705
+ "menu.shortcutLabel": "Open selection menu",
2706
+ "menu.addShortcutLabel": "Add selected text to task",
2707
+ "menu.noSelection": "Select text in the conversation first",
2708
+ "menu.busy": "Finish the current annotation first",
1822
2709
  "summary.hint": "Hover to view annotations",
2710
+ "summary.hintTouch": "Tap to view annotations",
1823
2711
  "summary.dialogAria": "Annotation details: {count}",
1824
2712
  "summary.edit": "Edit annotation {index}",
1825
2713
  "summary.remove": "Remove annotation {index}",
1826
2714
  "summary.userComment": "User comment",
1827
2715
  "summary.noComment": "No comment added",
1828
- "summary.aria.one": "{count} annotation. Hover to view details.",
1829
- "summary.aria.other": "{count} annotations. Hover to view details.",
2716
+ "summary.aria.one": "{count} annotation. Open to view details.",
2717
+ "summary.aria.other": "{count} annotations. Open to view details.",
1830
2718
  "summary.title.one": "{count} annotation",
1831
2719
  "summary.title.other": "{count} annotations",
1832
2720
  "summary.removeAll": "Remove all annotations",
1833
- "chip.annotation": "Annotation {index}"
2721
+ "chip.annotation": "Annotation {index}",
2722
+ "chip.previewAria": "Annotation preview",
2723
+ "chip.jump": "Original",
2724
+ "chip.jumpAria": "Jump to original",
2725
+ "settings.trigger": "Trigger",
2726
+ "settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
2727
+ "settings.trigger.auto": "Automatic",
2728
+ "settings.trigger.contextmenu": "Right-click",
2729
+ "settings.trigger.both": "Both",
2730
+ "settings.shortcut": "Shortcut",
2731
+ "settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
2732
+ "settings.shortcutListAria": "Active shortcuts",
2733
+ "settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
2734
+ "settings.shortcutUnbound": "Unbound",
2735
+ "settings.shortcutConflict": "Conflicts with another shortcut"
1834
2736
  };
1835
2737
  /**
1836
2738
  * Russian is not a shipped host locale: it is added through
@@ -1841,6 +2743,7 @@ const ru = {
1841
2743
  "common.selectedText": "Выделенный текст",
1842
2744
  "common.commentPlaceholder": "Добавить необязательный комментарий…",
1843
2745
  "common.cancel": "Отмена",
2746
+ "common.close": "Закрыть",
1844
2747
  "common.save": "Сохранить",
1845
2748
  "toolbar.aria": "Действия с выделенным текстом",
1846
2749
  "toolbar.commentHint": "Добавить аннотацию…",
@@ -1851,22 +2754,42 @@ const ru = {
1851
2754
  "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1852
2755
  "toolbar.skipComment": "Без комментария",
1853
2756
  "toolbar.addComment": "Добавить комментарий",
2757
+ "menu.aria": "Меню выделения",
2758
+ "menu.shortcutLabel": "Открыть меню выделения",
2759
+ "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
2760
+ "menu.noSelection": "Сначала выделите текст в диалоге",
2761
+ "menu.busy": "Сначала завершите текущую аннотацию",
1854
2762
  "summary.hint": "Наведите, чтобы увидеть аннотации",
2763
+ "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
1855
2764
  "summary.dialogAria": "Детали аннотаций: {count}",
1856
2765
  "summary.edit": "Изменить аннотацию {index}",
1857
2766
  "summary.remove": "Удалить аннотацию {index}",
1858
2767
  "summary.userComment": "Комментарий пользователя",
1859
2768
  "summary.noComment": "Комментарий не добавлен",
1860
- "summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
1861
- "summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
1862
- "summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1863
- "summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
2769
+ "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2770
+ "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2771
+ "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2772
+ "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
1864
2773
  "summary.title.one": "{count} аннотация",
1865
2774
  "summary.title.few": "{count} аннотации",
1866
2775
  "summary.title.many": "{count} аннотаций",
1867
2776
  "summary.title.other": "{count} аннотаций",
1868
2777
  "summary.removeAll": "Удалить все аннотации",
1869
- "chip.annotation": "Аннотация {index}"
2778
+ "chip.annotation": "Аннотация {index}",
2779
+ "chip.previewAria": "Просмотр аннотации",
2780
+ "chip.jump": "Оригинал",
2781
+ "chip.jumpAria": "Перейти к оригиналу",
2782
+ "settings.trigger": "Триггер",
2783
+ "settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
2784
+ "settings.trigger.auto": "Автоматически",
2785
+ "settings.trigger.contextmenu": "По правому клику",
2786
+ "settings.trigger.both": "Оба способа",
2787
+ "settings.shortcut": "Горячая клавиша",
2788
+ "settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
2789
+ "settings.shortcutListAria": "Текущие сочетания",
2790
+ "settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
2791
+ "settings.shortcutUnbound": "Не назначено",
2792
+ "settings.shortcutConflict": "Конфликтует с другим сочетанием"
1870
2793
  };
1871
2794
  /** Both shipped host locales, required together by the typed `register()`. */
1872
2795
  const SHIPPED_DICTS = Object.freeze({
@@ -1948,10 +2871,18 @@ function useLocale() {
1948
2871
  }
1949
2872
  //#endregion
1950
2873
  //#region src/client/ui/AnnotationCardView.tsx
1951
- /** Grace period so pointer transit between the summary and the panel never closes it. */
1952
- const CLOSE_DELAY = 320;
2874
+ /** Short grace for crossing the 12px bridge; the exit starts right after it. */
2875
+ const CLOSE_DELAY$1 = 40;
1953
2876
  /** After a viewport change, wait this long before position-checking the pointer. */
1954
2877
  const SETTLE_DELAY = 600;
2878
+ /** Transparent strip on the facing side; the pointer crosses it in transit. */
2879
+ const BRIDGE = 12;
2880
+ /** Matches the list's leaving keyframe; the panel unmounts after it. */
2881
+ const EXIT_MS$1 = 120;
2882
+ /** Stable edit target: item id, with an index fallback for id-less callers. */
2883
+ function editKeyOf(item, index) {
2884
+ return item?.id ?? `index:${index}`;
2885
+ }
1955
2886
  /**
1956
2887
  * Qoder-style summary with a placement-aware hover list.
1957
2888
  *
@@ -1971,21 +2902,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1971
2902
  const settleTimerRef = (0, react.useRef)(null);
1972
2903
  const lastPointerRef = (0, react.useRef)(null);
1973
2904
  const editInputRef = (0, react.useRef)(null);
2905
+ /** Set while keyboard focus is being restored after a deliberate close. */
2906
+ const focusGuardRef = (0, react.useRef)(false);
2907
+ /** Set by keyboard open; the effect focuses the panel once it is mounted. */
2908
+ const enterPanelRef = (0, react.useRef)(false);
2909
+ const panelId = (0, react.useId)();
1974
2910
  const [open, setOpen] = (0, react.useState)(false);
2911
+ const [renderPanel, setRenderPanel] = (0, react.useState)(false);
2912
+ const [closingPanel, setClosingPanel] = (0, react.useState)(false);
2913
+ const exitTimerRef = (0, react.useRef)(null);
1975
2914
  const [editing, setEditing] = (0, react.useState)(null);
1976
2915
  const [editDraft, setEditDraft] = (0, react.useState)("");
1977
2916
  const place = (0, react.useCallback)(() => {
1978
2917
  const root = rootRef.current;
1979
2918
  const pop = popRef.current;
1980
2919
  if (!root || !pop) return;
1981
- const { top, left, side } = placePopover(root.getBoundingClientRect(), {
1982
- width: pop.offsetWidth || 300,
1983
- height: pop.offsetHeight || 220
2920
+ const anchor = root.getBoundingClientRect();
2921
+ const inner = pop.firstElementChild instanceof HTMLElement ? pop.firstElementChild : null;
2922
+ const { top, left, side } = placePopover(anchor, {
2923
+ width: inner?.offsetWidth || pop.offsetWidth || 300,
2924
+ height: (inner?.offsetHeight || pop.offsetHeight || 220) + BRIDGE
1984
2925
  }, "top", 0);
1985
2926
  pop.style.top = `${top}px`;
1986
2927
  pop.style.left = `${left}px`;
1987
- pop.style.paddingTop = side === "bottom" ? "12px" : "12px";
1988
- pop.style.paddingBottom = side === "top" ? "12px" : "12px";
2928
+ pop.style.paddingTop = side === "bottom" ? `${BRIDGE}px` : "0";
2929
+ pop.style.paddingBottom = side === "top" ? `${BRIDGE}px` : "0";
1989
2930
  pop.dataset.side = side;
1990
2931
  pop.style.opacity = "1";
1991
2932
  }, []);
@@ -2003,11 +2944,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2003
2944
  cancelClose();
2004
2945
  setOpen(false);
2005
2946
  }, [cancelClose]);
2947
+ /** Keyboard-focusable controls in the open panel, in DOM order. */
2948
+ const focusables = (0, react.useCallback)(() => {
2949
+ const pop = popRef.current;
2950
+ if (!pop) return [];
2951
+ return Array.from(pop.querySelectorAll("button, textarea, [href], [tabindex]:not([tabindex=\"-1\"])")).filter((el) => !el.hasAttribute("disabled") && el.getClientRects().length > 0);
2952
+ }, []);
2953
+ const focusPanel = (0, react.useCallback)((edge = "first") => {
2954
+ const list = focusables();
2955
+ (edge === "first" ? list[0] : list[list.length - 1])?.focus({ preventScroll: true });
2956
+ }, [focusables]);
2957
+ /** Close the panel and put focus back on the summary without re-opening it. */
2958
+ const closeAndRestoreFocus = (0, react.useCallback)(() => {
2959
+ focusGuardRef.current = true;
2960
+ closePop();
2961
+ rootRef.current?.focus({ preventScroll: true });
2962
+ window.setTimeout(() => {
2963
+ focusGuardRef.current = false;
2964
+ }, 0);
2965
+ }, [closePop]);
2966
+ const openAndEnterPanel = (0, react.useCallback)(() => {
2967
+ enterPanelRef.current = true;
2968
+ openPop();
2969
+ }, [openPop]);
2006
2970
  const scheduleClose = (0, react.useCallback)(() => {
2007
2971
  cancelClose();
2008
2972
  if (editing !== null) return;
2009
- closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
2973
+ closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
2010
2974
  }, [cancelClose, editing]);
2975
+ /** Touch pointers emit pointerleave on lift; only hover pointers auto-close. */
2976
+ const onPointerLeave = (0, react.useCallback)((event) => {
2977
+ if (event?.pointerType === "touch") return;
2978
+ scheduleClose();
2979
+ }, [scheduleClose]);
2980
+ const onSummaryPointerUp = (0, react.useCallback)((event) => {
2981
+ if (event.pointerType !== "touch") return;
2982
+ const target = event.target;
2983
+ if (target instanceof Element && target.closest("button, textarea") !== null) return;
2984
+ openPop();
2985
+ }, [openPop]);
2986
+ (0, react.useEffect)(() => {
2987
+ if (!open) return;
2988
+ const onPointerDown = (event) => {
2989
+ const target = event.target;
2990
+ if (!(target instanceof Node)) return;
2991
+ if (rootRef.current?.contains(target) || popRef.current?.contains(target)) return;
2992
+ if (editing !== null) return;
2993
+ setOpen(false);
2994
+ };
2995
+ document.addEventListener("pointerdown", onPointerDown, true);
2996
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
2997
+ }, [open, editing]);
2998
+ (0, react.useLayoutEffect)(() => {
2999
+ if (open) {
3000
+ if (exitTimerRef.current !== null) {
3001
+ window.clearTimeout(exitTimerRef.current);
3002
+ exitTimerRef.current = null;
3003
+ }
3004
+ setClosingPanel(false);
3005
+ setRenderPanel(true);
3006
+ return;
3007
+ }
3008
+ if (!renderPanel) return;
3009
+ setClosingPanel(true);
3010
+ exitTimerRef.current = window.setTimeout(() => {
3011
+ exitTimerRef.current = null;
3012
+ setRenderPanel(false);
3013
+ setClosingPanel(false);
3014
+ }, EXIT_MS$1);
3015
+ return () => {
3016
+ if (exitTimerRef.current !== null) {
3017
+ window.clearTimeout(exitTimerRef.current);
3018
+ exitTimerRef.current = null;
3019
+ }
3020
+ };
3021
+ }, [open, renderPanel]);
2011
3022
  /** True when the last seen pointer sits on the summary or the panel. */
2012
3023
  const pointerInsideCluster = (0, react.useCallback)(() => {
2013
3024
  const point = lastPointerRef.current;
@@ -2030,6 +3041,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2030
3041
  items,
2031
3042
  editing
2032
3043
  ]);
3044
+ (0, react.useLayoutEffect)(() => {
3045
+ if (!open || !enterPanelRef.current) return;
3046
+ enterPanelRef.current = false;
3047
+ focusPanel("first");
3048
+ }, [open, focusPanel]);
2033
3049
  (0, react.useEffect)(() => {
2034
3050
  if (!open) return;
2035
3051
  const track = (event) => {
@@ -2084,31 +3100,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2084
3100
  const onPointerEnter = (0, react.useCallback)(() => {
2085
3101
  openPop();
2086
3102
  }, [openPop]);
2087
- const onPointerLeave = (0, react.useCallback)(() => {
2088
- scheduleClose();
2089
- }, [scheduleClose]);
2090
3103
  const onBlur = (0, react.useCallback)((e) => {
3104
+ if (editing !== null) return;
2091
3105
  const next = e.relatedTarget;
2092
3106
  if (next instanceof Node) {
2093
3107
  if (rootRef.current?.contains(next)) return;
2094
3108
  if (popRef.current?.contains(next)) return;
2095
3109
  }
2096
3110
  closePop();
2097
- }, [closePop]);
3111
+ }, [closePop, editing]);
3112
+ /** Keyboard focus opens the panel; mouse/touch focus does not. */
3113
+ const onSummaryFocus = (0, react.useCallback)(() => {
3114
+ if (focusGuardRef.current) return;
3115
+ if (rootRef.current?.matches(":focus-visible")) openPop();
3116
+ }, [openPop]);
2098
3117
  const onKeyDown = (0, react.useCallback)((e) => {
2099
3118
  if (e.key === "Escape") {
3119
+ e.preventDefault();
2100
3120
  if (editing !== null) setEditing(null);
2101
- else closePop();
3121
+ else closeAndRestoreFocus();
3122
+ return;
2102
3123
  }
2103
- }, [closePop, editing]);
3124
+ if (e.key === "Enter" || e.key === " ") {
3125
+ if (e.target !== e.currentTarget) return;
3126
+ e.preventDefault();
3127
+ if (open) closePop();
3128
+ else openAndEnterPanel();
3129
+ return;
3130
+ }
3131
+ if (e.key === "ArrowDown" && e.target === e.currentTarget) {
3132
+ e.preventDefault();
3133
+ if (open) focusPanel("first");
3134
+ else openAndEnterPanel();
3135
+ return;
3136
+ }
3137
+ if (e.key === "Tab" && open && !e.shiftKey && e.target === e.currentTarget) {
3138
+ e.preventDefault();
3139
+ focusPanel("first");
3140
+ }
3141
+ }, [
3142
+ closeAndRestoreFocus,
3143
+ closePop,
3144
+ editing,
3145
+ focusPanel,
3146
+ open,
3147
+ openAndEnterPanel
3148
+ ]);
3149
+ const onPanelKeyDown = (0, react.useCallback)((e) => {
3150
+ if (e.key === "Escape") {
3151
+ e.preventDefault();
3152
+ e.stopPropagation();
3153
+ if (editing !== null) setEditing(null);
3154
+ else closeAndRestoreFocus();
3155
+ return;
3156
+ }
3157
+ if (e.key === "Tab" || e.key === "ArrowDown" || e.key === "ArrowUp") e.stopPropagation();
3158
+ const list = focusables();
3159
+ if (list.length === 0) return;
3160
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3161
+ const index = active === null ? -1 : list.indexOf(active);
3162
+ if (e.key === "Tab") {
3163
+ if (list.length === 1) {
3164
+ e.preventDefault();
3165
+ list[0].focus();
3166
+ } else if (!e.shiftKey && index === list.length - 1) {
3167
+ e.preventDefault();
3168
+ list[0].focus();
3169
+ } else if (e.shiftKey && index <= 0) {
3170
+ e.preventDefault();
3171
+ list[list.length - 1].focus();
3172
+ }
3173
+ return;
3174
+ }
3175
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
3176
+ if (e.target instanceof HTMLTextAreaElement) return;
3177
+ e.preventDefault();
3178
+ list[(index + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus({ preventScroll: true });
3179
+ }, [
3180
+ closeAndRestoreFocus,
3181
+ editing,
3182
+ focusables
3183
+ ]);
2104
3184
  const startEdit = (0, react.useCallback)((index) => {
2105
- setEditing(index);
2106
- setEditDraft(items[index]?.comment ?? "");
3185
+ const item = items[index];
3186
+ if (!item) return;
3187
+ setEditing(editKeyOf(item, index));
3188
+ setEditDraft(item.comment ?? "");
2107
3189
  }, [items]);
2108
3190
  const commitEdit = (0, react.useCallback)(() => {
2109
3191
  if (editing === null) return;
2110
- const item = items[editing];
2111
- if (item) onItemEdit?.(editing, item, editDraft);
3192
+ const index = items.findIndex((item, i) => editKeyOf(item, i) === editing);
3193
+ const item = items[index];
3194
+ if (item) onItemEdit?.(index, item, editDraft);
2112
3195
  setEditing(null);
2113
3196
  }, [
2114
3197
  editing,
@@ -2116,19 +3199,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2116
3199
  editDraft,
2117
3200
  onItemEdit
2118
3201
  ]);
2119
- const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3202
+ const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2120
3203
  ref: popRef,
2121
- className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
3204
+ id: panelId,
3205
+ className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
2122
3206
  role: "dialog",
2123
3207
  "aria-label": t("summary.dialogAria", { count }),
2124
3208
  onPointerEnter: openPop,
2125
3209
  onPointerLeave,
3210
+ onKeyDown: onPanelKeyDown,
2126
3211
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2127
3212
  className: cx(A.col, styles.summaryHoverInner),
2128
3213
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2129
3214
  className: cx(A.col, styles.summaryList),
2130
3215
  children: items.map((item, i) => {
2131
- const isEditing = editing === i;
3216
+ const itemKey = editKeyOf(item, i);
3217
+ const isEditing = editing === itemKey;
2132
3218
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2133
3219
  className: styles.summaryItem,
2134
3220
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2164,7 +3250,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2164
3250
  className: styles.summaryItemMain,
2165
3251
  children: [
2166
3252
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2167
- className: styles.annEditText,
3253
+ className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
2168
3254
  children: item.text
2169
3255
  }),
2170
3256
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -2180,6 +3266,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2180
3266
  onKeyDown: (e) => {
2181
3267
  if (e.key === "Escape") {
2182
3268
  e.preventDefault();
3269
+ e.stopPropagation();
2183
3270
  setEditing(null);
2184
3271
  return;
2185
3272
  }
@@ -2198,7 +3285,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2198
3285
  children: t("common.cancel")
2199
3286
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2200
3287
  type: "button",
2201
- className: styles.annEditSave,
3288
+ className: cx(A.plain, styles.annEditSave),
2202
3289
  onClick: commitEdit,
2203
3290
  children: t("common.save")
2204
3291
  })]
@@ -2241,9 +3328,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2241
3328
  "data-selection-annotation-summary": "true",
2242
3329
  "aria-label": t("summary.aria", { count }),
2243
3330
  "aria-expanded": open,
3331
+ "aria-haspopup": "dialog",
3332
+ "aria-controls": open ? panelId : void 0,
2244
3333
  onPointerEnter,
2245
3334
  onPointerLeave,
2246
- onFocus: onPointerEnter,
3335
+ onPointerUp: onSummaryPointerUp,
3336
+ onFocus: onSummaryFocus,
2247
3337
  onBlur,
2248
3338
  onKeyDown,
2249
3339
  children: [
@@ -2254,13 +3344,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2254
3344
  }),
2255
3345
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2256
3346
  className: cx(A.grow, styles.summaryBody),
2257
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2258
- className: styles.summaryTitle,
2259
- children: t("summary.title", { count })
2260
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2261
- className: styles.summaryHint,
2262
- children: t("summary.hint")
2263
- })]
3347
+ children: [
3348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3349
+ className: styles.summaryTitle,
3350
+ children: t("summary.title", { count })
3351
+ }),
3352
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3353
+ className: cx(styles.summaryHint, styles.summaryHintPointer),
3354
+ children: t("summary.hint")
3355
+ }),
3356
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3357
+ className: cx(styles.summaryHint, styles.summaryHintTouch),
3358
+ children: t("summary.hintTouch")
3359
+ })
3360
+ ]
2264
3361
  }),
2265
3362
  onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2266
3363
  type: "button",
@@ -2321,8 +3418,7 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef
2321
3418
  onFolded();
2322
3419
  };
2323
3420
  const onKeyDown = (event) => {
2324
- if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
2325
- if (event.isComposing) return;
3421
+ if (!isSubmitKey(event)) return;
2326
3422
  const target = event.target;
2327
3423
  if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
2328
3424
  const card = composerCardOf(target);
@@ -2524,6 +3620,67 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2524
3620
  /** Alias kept for overlay registration. */
2525
3621
  const QuoteCard = AnnotationPanel;
2526
3622
  //#endregion
3623
+ //#region src/client/config.ts
3624
+ const DEFAULT_CONFIG = Object.freeze({
3625
+ triggerMode: "auto",
3626
+ shortcut: false
3627
+ });
3628
+ const TRIGGER_MODES = /* @__PURE__ */ new Set([
3629
+ "auto",
3630
+ "contextmenu",
3631
+ "both"
3632
+ ]);
3633
+ /** Collapse an untrusted snapshot value onto the known shape. */
3634
+ function normalizeConfig(value) {
3635
+ if (typeof value !== "object" || value === null) return { ...DEFAULT_CONFIG };
3636
+ const record = value;
3637
+ return {
3638
+ triggerMode: TRIGGER_MODES.has(record.triggerMode) ? record.triggerMode : DEFAULT_CONFIG.triggerMode,
3639
+ shortcut: typeof record.shortcut === "boolean" ? record.shortcut : DEFAULT_CONFIG.shortcut
3640
+ };
3641
+ }
3642
+ function resolveTriggers(config) {
3643
+ return {
3644
+ auto: config.triggerMode === "auto" || config.triggerMode === "both",
3645
+ contextMenu: config.triggerMode === "contextmenu" || config.triggerMode === "both",
3646
+ shortcut: config.shortcut
3647
+ };
3648
+ }
3649
+ let current = { ...DEFAULT_CONFIG };
3650
+ const listeners = /* @__PURE__ */ new Set();
3651
+ function getConfig() {
3652
+ return current;
3653
+ }
3654
+ function subscribeConfig(listener) {
3655
+ listeners.add(listener);
3656
+ return () => {
3657
+ listeners.delete(listener);
3658
+ };
3659
+ }
3660
+ /** Publish a snapshot value; returns the normalized config actually stored. */
3661
+ function publishConfig(value) {
3662
+ const next = normalizeConfig(value);
3663
+ if (next.triggerMode === current.triggerMode && next.shortcut === current.shortcut) return current;
3664
+ current = next;
3665
+ for (const listener of [...listeners]) listener();
3666
+ return current;
3667
+ }
3668
+ /**
3669
+ * Follow one Host config form. Works with a live form and with the fake one in
3670
+ * `verify`; the caller owns disposal.
3671
+ */
3672
+ function attachConfigForm(form) {
3673
+ const publish = () => {
3674
+ publishConfig(form.getSnapshot().value);
3675
+ };
3676
+ publish();
3677
+ const off = form.subscribe(publish);
3678
+ return () => {
3679
+ off();
3680
+ publishConfig(void 0);
3681
+ };
3682
+ }
3683
+ //#endregion
2527
3684
  //#region src/client/dom/floating-layer.ts
2528
3685
  /**
2529
3686
  * Mount point for the selection UI: a zero-size host inside the transcript's
@@ -2545,10 +3702,35 @@ const QuoteCard = AnnotationPanel;
2545
3702
  const HOST_ATTR = "data-dsq-layer";
2546
3703
  /** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
2547
3704
  const HOST_Z = 6;
3705
+ /**
3706
+ * Raised while the comment step is open. The composer summary card lives in
3707
+ * the seat (z 7) and can extend over the scroller area; the popover, clipped
3708
+ * to the scroller, would otherwise paint underneath it.
3709
+ */
3710
+ const HOST_Z_ABOVE_COMPOSER = 8;
2548
3711
  const layers = /* @__PURE__ */ new WeakMap();
2549
3712
  /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2550
3713
  const itemLayers = /* @__PURE__ */ new WeakMap();
2551
3714
  let fallback = null;
3715
+ const SEAT_SELECTOR = "[data-composer-seat]";
3716
+ const CARD_SELECTOR = "[data-composer-card]";
3717
+ /**
3718
+ * The composer belonging to `scroller`, or the document's first one when the
3719
+ * scroller has no seat in its ancestor chain.
3720
+ *
3721
+ * The desktop layout puts the sticky seat outside the scroll container; a
3722
+ * document-wide lookup picks the first chat on the page, which is wrong when
3723
+ * more than one conversation is mounted. Walk out from the scroller and take
3724
+ * the first seat inside the nearest ancestor that has one.
3725
+ */
3726
+ function composerSeatFor(scroller) {
3727
+ if (typeof document === "undefined") return null;
3728
+ if (scroller !== null) for (let el = scroller; el !== null && el !== document.body; el = el.parentElement) {
3729
+ const seat = el.querySelector(SEAT_SELECTOR) ?? el.querySelector(CARD_SELECTOR);
3730
+ if (seat !== null) return seat;
3731
+ }
3732
+ return document.querySelector(SEAT_SELECTOR) ?? document.querySelector(CARD_SELECTOR);
3733
+ }
2552
3734
  /**
2553
3735
  * Top edge of the composer seat, in viewport coordinates.
2554
3736
  *
@@ -2558,11 +3740,8 @@ let fallback = null;
2558
3740
  * parks popovers under the input box — the bottom edge must be the composer.
2559
3741
  * In the regular layout the seat sits below the scroller, so it changes nothing.
2560
3742
  */
2561
- function composerTop() {
2562
- if (typeof document === "undefined") return null;
2563
- const seat = document.querySelector("[data-composer-seat]") ?? document.querySelector("[data-composer-card]");
2564
- if (seat === null) return null;
2565
- return seat.getBoundingClientRect().top;
3743
+ function composerTop(scroller) {
3744
+ return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
2566
3745
  }
2567
3746
  function contentCoords(scroller, left, top) {
2568
3747
  const rect = scroller.getBoundingClientRect();
@@ -2589,7 +3768,7 @@ function layerFor(scroller) {
2589
3768
  const rect = scroller.getBoundingClientRect();
2590
3769
  const top = rect.top + scroller.clientTop;
2591
3770
  const rawBottom = top + scroller.clientHeight;
2592
- const cover = composerTop();
3771
+ const cover = composerTop(scroller);
2593
3772
  const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
2594
3773
  return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
2595
3774
  },
@@ -2600,6 +3779,9 @@ function layerFor(scroller) {
2600
3779
  left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
2601
3780
  top: top + rect.top + scroller.clientTop - scroller.scrollTop
2602
3781
  };
3782
+ },
3783
+ setAboveComposer: (above) => {
3784
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
2603
3785
  }
2604
3786
  };
2605
3787
  layers.set(scroller, layer);
@@ -2616,7 +3798,7 @@ function fallbackLayer() {
2616
3798
  host,
2617
3799
  scroller: document.body,
2618
3800
  bounds: () => {
2619
- const bottom = composerTop() ?? window.innerHeight;
3801
+ const bottom = composerTop(null) ?? window.innerHeight;
2620
3802
  return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
2621
3803
  },
2622
3804
  toContent: (left, top) => ({
@@ -2626,7 +3808,10 @@ function fallbackLayer() {
2626
3808
  fromContent: (left, top) => ({
2627
3809
  left,
2628
3810
  top
2629
- })
3811
+ }),
3812
+ setAboveComposer: (above) => {
3813
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
3814
+ }
2630
3815
  };
2631
3816
  return fallback;
2632
3817
  }
@@ -2716,6 +3901,17 @@ function isFirstLineOnScreen(rect) {
2716
3901
  return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
2717
3902
  }
2718
3903
  /**
3904
+ * First-line box of the live selection in viewport coordinates.
3905
+ *
3906
+ * The keyboard entry has no pointer to anchor to, so it opens the same menu
3907
+ * beside the selected words; the first line is what the reader is looking at.
3908
+ */
3909
+ function selectionFirstLineRect() {
3910
+ const selection = window.getSelection();
3911
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
3912
+ return getRangeFirstLineRect(selection.getRangeAt(0)) ?? null;
3913
+ }
3914
+ /**
2719
3915
  * Read the live selection when it is a non-empty range inside a conversation
2720
3916
  * message and outside the composer / our own chrome.
2721
3917
  */
@@ -2756,62 +3952,643 @@ function readSelectionSnapshot(toolbarRoot) {
2756
3952
  function clearNativeSelection() {
2757
3953
  window.getSelection()?.removeAllRanges();
2758
3954
  }
2759
- async function copyText(text) {
2760
- if (navigator.clipboard?.writeText) {
2761
- await navigator.clipboard.writeText(text);
2762
- return;
2763
- }
2764
- const area = document.createElement("textarea");
2765
- area.value = text;
2766
- area.setAttribute("readonly", "");
2767
- area.style.position = "fixed";
2768
- area.style.left = "-9999px";
2769
- document.body.appendChild(area);
2770
- area.select();
2771
- document.execCommand("copy");
2772
- area.remove();
3955
+ async function copyText(text) {
3956
+ if (navigator.clipboard?.writeText) {
3957
+ await navigator.clipboard.writeText(text);
3958
+ return;
3959
+ }
3960
+ const area = document.createElement("textarea");
3961
+ area.value = text;
3962
+ area.setAttribute("readonly", "");
3963
+ area.style.position = "fixed";
3964
+ area.style.left = "-9999px";
3965
+ document.body.appendChild(area);
3966
+ area.select();
3967
+ document.execCommand("copy");
3968
+ area.remove();
3969
+ }
3970
+ //#endregion
3971
+ //#region src/client/dom/selection-url.ts
3972
+ /**
3973
+ * Link detection for the selection toolbar.
3974
+ *
3975
+ * A selection that is exactly one URL gets an "open in browser" action. Only
3976
+ * `http(s)` qualifies: the desktop shell hands those to `shell.openExternal`
3977
+ * (the real default browser), while every other scheme is refused by the app's
3978
+ * window-open handler — a button that silently does nothing is worse than no
3979
+ * button at all.
3980
+ */
3981
+ /** Punctuation a drag-selection tends to swallow around a link. */
3982
+ const EDGE_PUNCTUATION = /^[\s"'“”‘’()\[\]{}<>( )【】《》]+|[\s"'“”‘’()\[\]{}<>( )【】《》.,;:!?。,;:!?]+$/g;
3983
+ /** Schemes the app can actually hand to the system browser. */
3984
+ const OPENABLE = /* @__PURE__ */ new Set(["http:", "https:"]);
3985
+ /** The selected text as a URL, or null when it is not one. */
3986
+ function selectionUrl(text) {
3987
+ const trimmed = text.trim();
3988
+ if (trimmed === "" || /\s/.test(trimmed.replace(EDGE_PUNCTUATION, ""))) return null;
3989
+ const candidate = trimmed.replace(EDGE_PUNCTUATION, "");
3990
+ if (!/^https?:\/\//i.test(candidate)) return null;
3991
+ try {
3992
+ const url = new URL(candidate);
3993
+ if (!OPENABLE.has(url.protocol)) return null;
3994
+ return url.href;
3995
+ } catch {
3996
+ return null;
3997
+ }
3998
+ }
3999
+ /**
4000
+ * URL the toolbar's open action should use.
4001
+ *
4002
+ * The selected text wins when it is itself a URL; otherwise the anchor the
4003
+ * selection sits in decides — chat links are usually rendered as wording, so
4004
+ * selecting "点击这里" must still offer to open what it points at.
4005
+ */
4006
+ function linkTarget(text, anchorHref) {
4007
+ return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
4008
+ }
4009
+ //#endregion
4010
+ //#region src/client/selection/SelectionMenu.tsx
4011
+ /** Space between the anchor line's bottom and the menu. */
4012
+ const ANCHOR_GAP = 2;
4013
+ function SelectionMenu({ snapshot, x, y, onAction, onClose, initialFocus = "first" }) {
4014
+ const rootRef = (0, react.useRef)(null);
4015
+ const firstRef = (0, react.useRef)(null);
4016
+ const [placement, setPlacement] = (0, react.useState)(null);
4017
+ const items = [
4018
+ ...linkTarget(snapshot.text, snapshot.anchorHref) ? [{
4019
+ key: "open",
4020
+ icon: "external-link",
4021
+ label: t("toolbar.openLink")
4022
+ }] : [],
4023
+ {
4024
+ key: "copy",
4025
+ icon: "copy",
4026
+ label: t("toolbar.copy")
4027
+ },
4028
+ {
4029
+ key: "add",
4030
+ icon: "plus",
4031
+ label: t("toolbar.addToTask")
4032
+ }
4033
+ ];
4034
+ (0, react.useLayoutEffect)(() => {
4035
+ const el = rootRef.current;
4036
+ if (!el) return;
4037
+ const anchor = DOMRect.fromRect({
4038
+ x,
4039
+ y,
4040
+ width: 0,
4041
+ height: 0
4042
+ });
4043
+ setPlacement(placePopover(anchor, {
4044
+ width: el.offsetWidth,
4045
+ height: el.offsetHeight
4046
+ }, "bottom", ANCHOR_GAP));
4047
+ }, [
4048
+ x,
4049
+ y,
4050
+ items.length
4051
+ ]);
4052
+ (0, react.useLayoutEffect)(() => {
4053
+ if (!placement) return;
4054
+ if (initialFocus === "menu") rootRef.current?.focus({ preventScroll: true });
4055
+ else firstRef.current?.focus({ preventScroll: true });
4056
+ }, [placement, initialFocus]);
4057
+ (0, react.useEffect)(() => {
4058
+ const inside = (node) => node instanceof Node && rootRef.current?.contains(node) === true;
4059
+ const onPointerDown = (event) => {
4060
+ if (inside(event.target)) return;
4061
+ onClose();
4062
+ };
4063
+ const onContextMenu = (event) => {
4064
+ if (inside(event.target)) {
4065
+ event.preventDefault();
4066
+ return;
4067
+ }
4068
+ onClose();
4069
+ };
4070
+ const onScroll = () => onClose();
4071
+ const onResize = () => onClose();
4072
+ const onBlur = () => onClose();
4073
+ document.addEventListener("pointerdown", onPointerDown, true);
4074
+ document.addEventListener("contextmenu", onContextMenu, true);
4075
+ document.addEventListener("scroll", onScroll, true);
4076
+ window.addEventListener("resize", onResize);
4077
+ window.addEventListener("blur", onBlur);
4078
+ return () => {
4079
+ document.removeEventListener("pointerdown", onPointerDown, true);
4080
+ document.removeEventListener("contextmenu", onContextMenu, true);
4081
+ document.removeEventListener("scroll", onScroll, true);
4082
+ window.removeEventListener("resize", onResize);
4083
+ window.removeEventListener("blur", onBlur);
4084
+ };
4085
+ }, [onClose]);
4086
+ const handleKeyDown = (0, react.useCallback)((event) => {
4087
+ if (event.key === "Escape") {
4088
+ event.preventDefault();
4089
+ event.stopPropagation();
4090
+ onClose();
4091
+ return;
4092
+ }
4093
+ if (event.key === "Tab") {
4094
+ onClose();
4095
+ return;
4096
+ }
4097
+ const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
4098
+ if (step === 0 && event.key !== "Home" && event.key !== "End") return;
4099
+ const buttons = [...rootRef.current?.querySelectorAll("[role=\"menuitem\"]") ?? []];
4100
+ if (buttons.length === 0) return;
4101
+ event.preventDefault();
4102
+ const current = buttons.indexOf(document.activeElement);
4103
+ const fromMenu = current === -1;
4104
+ buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : fromMenu ? step > 0 ? 0 : buttons.length - 1 : (current + step + buttons.length) % buttons.length]?.focus({ preventScroll: true });
4105
+ }, [onClose]);
4106
+ const keepSelection = (0, react.useCallback)((event) => {
4107
+ event.preventDefault();
4108
+ event.currentTarget.focus({ preventScroll: true });
4109
+ }, []);
4110
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4111
+ ref: rootRef,
4112
+ className: cx(A.col, styles.ctxMenu),
4113
+ role: "menu",
4114
+ "aria-label": t("menu.aria"),
4115
+ "data-dsq-menu": "true",
4116
+ "data-side": placement?.side ?? "bottom",
4117
+ tabIndex: -1,
4118
+ style: {
4119
+ top: placement?.top ?? 0,
4120
+ left: placement?.left ?? 0,
4121
+ visibility: placement ? "visible" : "hidden"
4122
+ },
4123
+ onKeyDown: handleKeyDown,
4124
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4125
+ ref: index === 0 ? firstRef : void 0,
4126
+ type: "button",
4127
+ role: "menuitem",
4128
+ className: cx(A.plain, A.row, styles.ctxItem),
4129
+ tabIndex: -1,
4130
+ onPointerDown: keepSelection,
4131
+ onPointerEnter: (event) => event.currentTarget.focus({ preventScroll: true }),
4132
+ onClick: () => onAction(item.key),
4133
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4134
+ name: item.icon,
4135
+ size: 15
4136
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.label })]
4137
+ }, item.key))
4138
+ }), document.body);
4139
+ }
4140
+ //#endregion
4141
+ //#region src/client/selection/ToolbarView.tsx
4142
+ /**
4143
+ * Paint-only halves of the selection UI: the anchored desktop toolbar/popover
4144
+ * and the touch bottom bar/sheet. All state, persistence and placement stay in
4145
+ * `SelectionToolbar`; this module owns markup, portals and ARIA only.
4146
+ */
4147
+ function ActionRow({ actions, stacked }) {
4148
+ return actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4149
+ className: styles.divider,
4150
+ "aria-hidden": "true"
4151
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4152
+ type: "button",
4153
+ className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
4154
+ onClick: action.onClick,
4155
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4156
+ name: action.icon,
4157
+ size: stacked ? 18 : 14
4158
+ }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
4159
+ })] }, action.key));
4160
+ }
4161
+ function CommentField({ comment, touch, commentRef, onChange, onKeyDown }) {
4162
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
4163
+ ref: commentRef,
4164
+ className: cx(A.box, styles.commentInput, touch && styles.touchComment),
4165
+ placeholder: t("common.commentPlaceholder"),
4166
+ value: comment,
4167
+ onChange: (event) => onChange(event.target.value),
4168
+ onKeyDown
4169
+ });
4170
+ }
4171
+ function CommentActions({ comment, touch, onFinishComment }) {
4172
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4173
+ className: cx(A.row, styles.commentActions, touch && styles.touchActions),
4174
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4175
+ type: "button",
4176
+ className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
4177
+ onClick: () => onFinishComment(false),
4178
+ children: t("toolbar.skipComment")
4179
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4180
+ type: "button",
4181
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
4182
+ disabled: !comment.trim(),
4183
+ onClick: () => onFinishComment(true),
4184
+ children: t("toolbar.addComment")
4185
+ })]
4186
+ });
4187
+ }
4188
+ function ToolbarView({ touch, phase, active, status, viewport, actions, comment, menuOpen, toolbarStyle, toolbarRef, commentWrapRef, commentRef, onToolbarPointerDown, onCommentChange, onCommentKeyDown, onFinishComment, onCloseSheet }) {
4189
+ const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentField, {
4190
+ comment,
4191
+ touch,
4192
+ commentRef,
4193
+ onChange: onCommentChange,
4194
+ onKeyDown: onCommentKeyDown
4195
+ });
4196
+ const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentActions, {
4197
+ comment,
4198
+ touch,
4199
+ onFinishComment
4200
+ });
4201
+ if (touch) {
4202
+ const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
4203
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4204
+ phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4205
+ ref: toolbarRef,
4206
+ className: cx(A.row, styles.touchBar),
4207
+ style: { bottom },
4208
+ role: "toolbar",
4209
+ "aria-label": t("toolbar.aria"),
4210
+ "data-dsq-toolbar": "true",
4211
+ "data-touch": "true",
4212
+ onPointerDown: onToolbarPointerDown,
4213
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4214
+ actions,
4215
+ stacked: true
4216
+ })
4217
+ }) : null,
4218
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4219
+ className: styles.touchScrim,
4220
+ "aria-hidden": "true"
4221
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4222
+ ref: commentWrapRef,
4223
+ className: cx(A.col, styles.touchSheet),
4224
+ style: {
4225
+ bottom,
4226
+ maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
4227
+ },
4228
+ role: "dialog",
4229
+ "aria-label": t("toolbar.commentDialogAria"),
4230
+ children: [
4231
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4232
+ className: cx(A.row, styles.touchSheetHead),
4233
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4234
+ className: styles.touchSheetTitle,
4235
+ children: t("toolbar.commentHint")
4236
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4237
+ type: "button",
4238
+ className: cx(A.plain, A.gridMid, styles.touchSheetClose),
4239
+ "aria-label": t("common.close"),
4240
+ onClick: onCloseSheet,
4241
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4242
+ name: "x",
4243
+ size: 18
4244
+ })
4245
+ })]
4246
+ }),
4247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4248
+ className: styles.touchQuote,
4249
+ children: active.text
4250
+ }),
4251
+ commentField,
4252
+ commentActions
4253
+ ]
4254
+ })] }) : null,
4255
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4256
+ className: styles.touchStatus,
4257
+ style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
4258
+ role: "status",
4259
+ children: status
4260
+ }) : null
4261
+ ] }), document.body);
4262
+ }
4263
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4264
+ menuOpen ? null : phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4265
+ ref: toolbarRef,
4266
+ className: cx(A.row, styles.toolbar),
4267
+ style: toolbarStyle,
4268
+ role: "toolbar",
4269
+ "aria-label": t("toolbar.aria"),
4270
+ "data-dsq-toolbar": "true",
4271
+ "data-anchor": active.below ? "below" : "above",
4272
+ onPointerDown: onToolbarPointerDown,
4273
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4274
+ className: cx(A.inlineRow, styles.toolbarInner),
4275
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4276
+ actions,
4277
+ stacked: false
4278
+ })
4279
+ })
4280
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4281
+ ref: toolbarRef,
4282
+ className: cx(A.row, styles.toolbar),
4283
+ role: "toolbar",
4284
+ "aria-label": t("toolbar.aria"),
4285
+ "data-dsq-toolbar": "true",
4286
+ "data-anchor": "popover-top",
4287
+ onPointerDown: onToolbarPointerDown,
4288
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4289
+ className: cx(A.inlineRow, styles.toolbarInner),
4290
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4291
+ style: {
4292
+ padding: "0 8px",
4293
+ fontSize: 13,
4294
+ opacity: .8,
4295
+ whiteSpace: "nowrap"
4296
+ },
4297
+ children: t("toolbar.commentHint")
4298
+ })
4299
+ })
4300
+ }),
4301
+ !menuOpen && phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4302
+ ref: commentWrapRef,
4303
+ className: cx(A.col, styles.commentPop),
4304
+ role: "dialog",
4305
+ "aria-label": t("toolbar.commentDialogAria"),
4306
+ onPointerDown: onToolbarPointerDown,
4307
+ children: [commentField, commentActions]
4308
+ }) : null,
4309
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4310
+ className: styles.status,
4311
+ style: toolbarStyle,
4312
+ role: "status",
4313
+ children: status
4314
+ }) : null
4315
+ ] }), active.layer.host);
4316
+ }
4317
+ //#endregion
4318
+ //#region src/client/dom/comment-placement.ts
4319
+ const GAP$1 = 8;
4320
+ /** Horizontal slack around the popover, and the minimum margin to the box. */
4321
+ const EDGE = 8;
4322
+ /** Gap between the popover corner and the "add a comment" toolbar. */
4323
+ const TOOLBAR_GAP = 6;
4324
+ function placeCommentSurfaces({ layer, selection, toolbar, panel }) {
4325
+ const bounds = layer.bounds();
4326
+ const home = layer.fromContent(selection.left, selection.top);
4327
+ const anchor = {
4328
+ left: home.left - toolbar.width / 2,
4329
+ top: home.top - (selection.below ? 0 : toolbar.height),
4330
+ width: toolbar.width,
4331
+ height: toolbar.height
4332
+ };
4333
+ const belowTop = layer.fromContent(0, selection.selectionBottom).top + GAP$1;
4334
+ const aboveTop = anchor.top - panel.height - GAP$1;
4335
+ const top = belowTop <= bounds.bottom - EDGE - panel.height ? belowTop : aboveTop >= bounds.top + EDGE ? aboveTop : Math.max(bounds.top + EDGE, Math.min(belowTop, bounds.bottom - EDGE - panel.height));
4336
+ const half = panel.width / 2 + EDGE;
4337
+ const min = bounds.left + half;
4338
+ const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
4339
+ const popover = layer.toContent(center, top);
4340
+ let leftView = layer.fromContent(popover.left, popover.top).left - panel.width / 2;
4341
+ let topView = top - TOOLBAR_GAP - toolbar.height;
4342
+ if (topView < bounds.top + EDGE) topView = top + panel.height + TOOLBAR_GAP;
4343
+ leftView = Math.max(bounds.left + EDGE, Math.min(leftView, bounds.right - EDGE - toolbar.width));
4344
+ topView = Math.max(bounds.top + EDGE, Math.min(topView, bounds.bottom - EDGE - toolbar.height));
4345
+ return {
4346
+ popover,
4347
+ toolbar: layer.toContent(leftView, topView)
4348
+ };
4349
+ }
4350
+ //#endregion
4351
+ //#region src/client/selection/useCommentPlacement.ts
4352
+ /**
4353
+ * Measures the comment popover and its hint toolbar, then writes the geometry
4354
+ * from `dom/comment-placement.ts` back as content coordinates. The React
4355
+ * component keeps the refs; this hook owns the measure/place lifecycle.
4356
+ */
4357
+ function useCommentPlacement({ touch, phase, active, toolbarRef, commentWrapRef }) {
4358
+ const place = (0, react.useCallback)(() => {
4359
+ if (touch) return;
4360
+ const layer = active?.layer;
4361
+ const toolbar = toolbarRef.current;
4362
+ const pop = commentWrapRef.current;
4363
+ if (!active || !layer || !toolbar || !pop) return;
4364
+ pop.style.display = "flex";
4365
+ pop.style.visibility = "hidden";
4366
+ const panel = {
4367
+ width: pop.offsetWidth || 320,
4368
+ height: pop.offsetHeight || 180
4369
+ };
4370
+ pop.style.visibility = "";
4371
+ const at = placeCommentSurfaces({
4372
+ layer,
4373
+ selection: active,
4374
+ toolbar: {
4375
+ width: toolbar.offsetWidth,
4376
+ height: toolbar.offsetHeight
4377
+ },
4378
+ panel
4379
+ });
4380
+ pop.style.top = `${at.popover.top}px`;
4381
+ pop.style.left = `${at.popover.left}px`;
4382
+ toolbar.style.left = `${at.toolbar.left}px`;
4383
+ toolbar.style.top = `${at.toolbar.top}px`;
4384
+ }, [
4385
+ active,
4386
+ commentWrapRef,
4387
+ toolbarRef,
4388
+ touch
4389
+ ]);
4390
+ (0, react.useLayoutEffect)(() => {
4391
+ if (phase === "comment") place();
4392
+ }, [phase, place]);
4393
+ (0, react.useEffect)(() => {
4394
+ if (touch) return;
4395
+ window.addEventListener("resize", place);
4396
+ return () => window.removeEventListener("resize", place);
4397
+ }, [place, touch]);
4398
+ return place;
2773
4399
  }
2774
4400
  //#endregion
2775
- //#region src/client/dom/selection-url.ts
4401
+ //#region src/client/dom/menu-trigger.ts
4402
+ function shouldOpenSelectionMenu(signal) {
4403
+ return signal.contextMenuEnabled && !signal.touch && !signal.defaultPrevented && signal.targetInTranscript && signal.pointInSelection && signal.hasSelection;
4404
+ }
2776
4405
  /**
2777
- * Link detection for the selection toolbar.
4406
+ * True when `(x, y)` falls inside any of the selection's line boxes.
2778
4407
  *
2779
- * A selection that is exactly one URL gets an "open in browser" action. Only
2780
- * `http(s)` qualifies: the desktop shell hands those to `shell.openExternal`
2781
- * (the real default browser), while every other scheme is refused by the app's
2782
- * window-open handler — a button that silently does nothing is worse than no
2783
- * button at all.
4408
+ * The union box is wrong here: a short first line leaves empty space to its
4409
+ * right that belongs to no word, and a right-click there is not "on the
4410
+ * selection". Each line rect plus a small tolerance matches what the user sees.
2784
4411
  */
2785
- /** Punctuation a drag-selection tends to swallow around a link. */
2786
- const EDGE_PUNCTUATION = /^[\s"'“”‘’()\[\]{}<>( )【】《》]+|[\s"'“”‘’()\[\]{}<>( )【】《》.,;:!?。,;:!?]+$/g;
2787
- /** Schemes the app can actually hand to the system browser. */
2788
- const OPENABLE = /* @__PURE__ */ new Set(["http:", "https:"]);
2789
- /** The selected text as a URL, or null when it is not one. */
2790
- function selectionUrl(text) {
2791
- const trimmed = text.trim();
2792
- if (trimmed === "" || /\s/.test(trimmed.replace(EDGE_PUNCTUATION, ""))) return null;
2793
- const candidate = trimmed.replace(EDGE_PUNCTUATION, "");
2794
- if (!/^https?:\/\//i.test(candidate)) return null;
2795
- try {
2796
- const url = new URL(candidate);
2797
- if (!OPENABLE.has(url.protocol)) return null;
2798
- return url.href;
2799
- } catch {
2800
- return null;
2801
- }
4412
+ function pointInRects(x, y, rects, tolerance = 2) {
4413
+ for (const rect of rects) if (x >= rect.left - tolerance && x <= rect.right + tolerance && y >= rect.top - tolerance && y <= rect.bottom + tolerance) return true;
4414
+ return false;
2802
4415
  }
4416
+ //#endregion
4417
+ //#region src/client/selection/useSelectionWatcher.ts
2803
4418
  /**
2804
- * URL the toolbar's open action should use.
4419
+ * Transcript-wide pointer/selection observation for the toolbar.
2805
4420
  *
2806
- * The selected text wins when it is itself a URL; otherwise the anchor the
2807
- * selection sits in decides — chat links are usually rendered as wording, so
2808
- * selecting "点击这里" must still offer to open what it points at.
4421
+ * The controller owns the state; this hook owns *when* to re-read it. Keeping
4422
+ * the listener choreography here means the toolbar component does not have to
4423
+ * know about the drag/context-menu/scroll ordering rules.
2809
4424
  */
2810
- function linkTarget(text, anchorHref) {
2811
- return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
4425
+ /** The menu's own chrome; a press inside it must not dismiss the selection. */
4426
+ const MENU_SELECTOR = "[data-dsq-menu]";
4427
+ function useSelectionWatcher({ toolbarRef, commentWrapRef, phaseRef, touch, dismissMenu, refresh, flushComment, hide, openMenu }) {
4428
+ (0, react.useEffect)(() => {
4429
+ let scheduled = false;
4430
+ const schedule = () => {
4431
+ if (scheduled) return;
4432
+ scheduled = true;
4433
+ window.setTimeout(() => {
4434
+ scheduled = false;
4435
+ refresh();
4436
+ }, 0);
4437
+ };
4438
+ let dragging = false;
4439
+ const onPointerDown = (event) => {
4440
+ const target = event.target;
4441
+ if (!(target instanceof Node)) return;
4442
+ const inToolbar = toolbarRef.current?.contains(target) === true;
4443
+ const inComment = commentWrapRef.current?.contains(target) === true;
4444
+ const inMenu = target instanceof Element && target.closest(MENU_SELECTOR) !== null;
4445
+ if (inToolbar || inComment || inMenu) return;
4446
+ dragging = true;
4447
+ if (phaseRef.current === "comment") {
4448
+ flushComment();
4449
+ hide();
4450
+ return;
4451
+ }
4452
+ const selection = window.getSelection();
4453
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
4454
+ schedule();
4455
+ return;
4456
+ }
4457
+ hide();
4458
+ };
4459
+ const onPointerUp = () => {
4460
+ dragging = false;
4461
+ schedule();
4462
+ };
4463
+ const onSelectionChange = () => {
4464
+ if (dragging) return;
4465
+ schedule();
4466
+ };
4467
+ const onContextMenu = (event) => {
4468
+ if (dismissMenu()) refresh();
4469
+ if (event.defaultPrevented) return;
4470
+ const target = event.target;
4471
+ if (!(target instanceof Node)) return;
4472
+ if (toolbarRef.current?.contains(target)) return;
4473
+ if (target instanceof Element && target.closest(MENU_SELECTOR)) return;
4474
+ if (touch || phaseRef.current === "comment") return;
4475
+ const contextMenuEnabled = resolveTriggers(getConfig()).contextMenu;
4476
+ if (!contextMenuEnabled) return;
4477
+ const selection = window.getSelection();
4478
+ const hasSelection = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;
4479
+ const pointInSelection = selection !== null && hasSelection && pointInRects(event.clientX, event.clientY, selection.getRangeAt(0).getClientRects());
4480
+ if (!shouldOpenSelectionMenu({
4481
+ defaultPrevented: event.defaultPrevented,
4482
+ contextMenuEnabled,
4483
+ touch,
4484
+ targetInTranscript: isInsideTranscript(target),
4485
+ pointInSelection,
4486
+ hasSelection
4487
+ })) return;
4488
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4489
+ if (!snapshot) return;
4490
+ event.preventDefault();
4491
+ event.stopPropagation();
4492
+ openMenu(snapshot, event.clientX, event.clientY, "menu");
4493
+ };
4494
+ const onScroll = (event) => {
4495
+ const target = event.target;
4496
+ if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
4497
+ schedule();
4498
+ };
4499
+ document.addEventListener("pointerdown", onPointerDown, true);
4500
+ document.addEventListener("pointerup", onPointerUp, true);
4501
+ document.addEventListener("keyup", schedule, true);
4502
+ document.addEventListener("selectionchange", onSelectionChange);
4503
+ document.addEventListener("contextmenu", onContextMenu);
4504
+ window.addEventListener("resize", schedule);
4505
+ document.addEventListener("scroll", onScroll, true);
4506
+ return () => {
4507
+ document.removeEventListener("pointerdown", onPointerDown, true);
4508
+ document.removeEventListener("pointerup", onPointerUp, true);
4509
+ document.removeEventListener("keyup", schedule, true);
4510
+ document.removeEventListener("selectionchange", onSelectionChange);
4511
+ document.removeEventListener("contextmenu", onContextMenu);
4512
+ window.removeEventListener("resize", schedule);
4513
+ document.removeEventListener("scroll", onScroll, true);
4514
+ };
4515
+ }, [
4516
+ commentWrapRef,
4517
+ dismissMenu,
4518
+ flushComment,
4519
+ hide,
4520
+ openMenu,
4521
+ phaseRef,
4522
+ refresh,
4523
+ toolbarRef,
4524
+ touch
4525
+ ]);
4526
+ }
4527
+ //#endregion
4528
+ //#region src/client/ui/touch.ts
4529
+ /**
4530
+ * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
4531
+ * criterion means a device never gets the bottom bar with desktop positioning
4532
+ * (or the reverse).
4533
+ */
4534
+ const COARSE_POINTER = "(hover: none), (pointer: coarse)";
4535
+ function coarsePointer() {
4536
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
4537
+ return window.matchMedia(COARSE_POINTER).matches;
4538
+ }
4539
+ function useCoarsePointer() {
4540
+ const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
4541
+ (0, react.useEffect)(() => {
4542
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
4543
+ const query = window.matchMedia(COARSE_POINTER);
4544
+ const onChange = (event) => setCoarse(event.matches);
4545
+ setCoarse(query.matches);
4546
+ query.addEventListener("change", onChange);
4547
+ return () => query.removeEventListener("change", onChange);
4548
+ }, []);
4549
+ return coarse;
4550
+ }
4551
+ function measure() {
4552
+ if (typeof window === "undefined") return {
4553
+ inset: 0,
4554
+ height: 0
4555
+ };
4556
+ const viewport = window.visualViewport;
4557
+ const layout = document.documentElement.clientHeight || window.innerHeight;
4558
+ if (!viewport) return {
4559
+ inset: 0,
4560
+ height: window.innerHeight
4561
+ };
4562
+ return {
4563
+ inset: Math.max(0, Math.round(layout - viewport.height - viewport.offsetTop)),
4564
+ height: Math.round(viewport.height)
4565
+ };
4566
+ }
4567
+ /**
4568
+ * Ride above the soft keyboard. `innerHeight` alone is not enough: iOS keeps the
4569
+ * layout viewport at full height and only shrinks the visual viewport, while
4570
+ * Android resizes the layout viewport. The inset is zero in the Android case
4571
+ * because the fixed element then already sits above the keyboard.
4572
+ */
4573
+ function useVisualViewport(enabled) {
4574
+ const [metrics, setMetrics] = (0, react.useState)(measure);
4575
+ (0, react.useEffect)(() => {
4576
+ if (!enabled || typeof window === "undefined") return;
4577
+ const update = () => setMetrics(measure());
4578
+ update();
4579
+ window.visualViewport?.addEventListener("resize", update);
4580
+ window.visualViewport?.addEventListener("scroll", update);
4581
+ window.addEventListener("resize", update);
4582
+ return () => {
4583
+ window.visualViewport?.removeEventListener("resize", update);
4584
+ window.visualViewport?.removeEventListener("scroll", update);
4585
+ window.removeEventListener("resize", update);
4586
+ };
4587
+ }, [enabled]);
4588
+ return metrics;
2812
4589
  }
2813
4590
  //#endregion
2814
- //#region src/client/SelectionToolbar.tsx
4591
+ //#region src/client/selection/SelectionToolbar.tsx
2815
4592
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2816
4593
  const COMMENT_MAX_HEIGHT = 220;
2817
4594
  function SelectionToolbar({ sessionId }) {
@@ -2823,21 +4600,40 @@ function SelectionToolbar({ sessionId }) {
2823
4600
  const [phase, setPhase] = (0, react.useState)("idle");
2824
4601
  const [comment, setComment] = (0, react.useState)("");
2825
4602
  const [status, setStatus] = (0, react.useState)(null);
4603
+ const [menu, setMenu] = (0, react.useState)(null);
2826
4604
  const activeRef = (0, react.useRef)(null);
2827
4605
  activeRef.current = active;
4606
+ const menuRef = (0, react.useRef)(null);
4607
+ menuRef.current = menu;
4608
+ const phaseRef = (0, react.useRef)(phase);
4609
+ phaseRef.current = phase;
2828
4610
  /** Annotation saved when the comment step opened; the box only edits it. */
2829
4611
  const pendingIdRef = (0, react.useRef)(null);
4612
+ const touch = useCoarsePointer();
4613
+ const viewport = useVisualViewport(touch);
2830
4614
  (0, react.useEffect)(() => {
2831
4615
  ensureToolbarStyles();
2832
4616
  if (sessionId) bindAnnotationSession(sessionId);
2833
4617
  }, [sessionId]);
4618
+ /**
4619
+ * Drop the menu state **and** its ref in one tick. A refresh that runs right
4620
+ * after a dismissal must see the menu as closed; reading the stale ref made
4621
+ * it keep `active`, which raised the auto toolbar in contextmenu mode.
4622
+ */
4623
+ const dismissMenu = (0, react.useCallback)(() => {
4624
+ if (!menuRef.current) return false;
4625
+ menuRef.current = null;
4626
+ setMenu(null);
4627
+ return true;
4628
+ }, []);
2834
4629
  const hide = (0, react.useCallback)(() => {
2835
4630
  pendingIdRef.current = null;
4631
+ dismissMenu();
2836
4632
  setActive(null);
2837
4633
  setPhase("idle");
2838
4634
  setComment("");
2839
4635
  setStatus(null);
2840
- }, []);
4636
+ }, [dismissMenu]);
2841
4637
  /**
2842
4638
  * Keep what was typed when the comment popover goes away without an explicit
2843
4639
  * cancel. The annotation itself was saved when the step opened, so dropping
@@ -2851,9 +4647,7 @@ function SelectionToolbar({ sessionId }) {
2851
4647
  if (typed.trim()) updateAnnotationComment(void 0, id, typed);
2852
4648
  }, []);
2853
4649
  /** Save the current selection without a comment; returns the store id. */
2854
- const saveSelection = (0, react.useCallback)(() => {
2855
- const snapshot = activeRef.current;
2856
- if (!snapshot) return null;
4650
+ const saveSelection = (0, react.useCallback)((snapshot) => {
2857
4651
  return saveAnnotation(void 0, {
2858
4652
  text: snapshot.text,
2859
4653
  sources: snapshot.source ? [{
@@ -2865,12 +4659,31 @@ function SelectionToolbar({ sessionId }) {
2865
4659
  }] : void 0
2866
4660
  }).id;
2867
4661
  }, []);
4662
+ /**
4663
+ * "Add to task" is the commit: the quote is saved immediately, with or
4664
+ * without a comment. The popover only edits that saved entry, so dismissing
4665
+ * it (outside click, new selection) can never undo the annotation.
4666
+ */
4667
+ const beginComment = (0, react.useCallback)((snapshot) => {
4668
+ pendingIdRef.current = saveSelection(snapshot);
4669
+ setActive(snapshot);
4670
+ dismissMenu();
4671
+ setPhase("comment");
4672
+ setComment("");
4673
+ setStatus(null);
4674
+ }, [dismissMenu, saveSelection]);
2868
4675
  const refresh = (0, react.useCallback)(() => {
2869
4676
  const next = readSelectionSnapshot(toolbarRef.current);
2870
4677
  if (!next) {
4678
+ dismissMenu();
2871
4679
  if (phaseRef.current === "idle") setActive(null);
2872
4680
  return;
2873
4681
  }
4682
+ if (menuRef.current && menuRef.current.snapshot.text !== next.text) dismissMenu();
4683
+ if (!touch && !resolveTriggers(getConfig()).auto) {
4684
+ if (phaseRef.current === "idle" && !menuRef.current) setActive(null);
4685
+ return;
4686
+ }
2874
4687
  const current = activeRef.current;
2875
4688
  if (phaseRef.current === "comment" && current && current.text !== next.text) {
2876
4689
  flushComment();
@@ -2878,151 +4691,107 @@ function SelectionToolbar({ sessionId }) {
2878
4691
  setComment("");
2879
4692
  }
2880
4693
  setActive((prev) => prev && prev.text === next.text && prev.left === next.left && prev.top === next.top && prev.below === next.below && prev.layer === next.layer ? prev : next);
2881
- }, [flushComment]);
2882
- const phaseRef = (0, react.useRef)(phase);
2883
- phaseRef.current = phase;
4694
+ }, [
4695
+ dismissMenu,
4696
+ flushComment,
4697
+ touch
4698
+ ]);
4699
+ /** Latch the selection and anchor; the menu outlives later refresh passes. */
4700
+ const openMenu = (0, react.useCallback)((snapshot, x, y, initialFocus) => {
4701
+ setStatus(null);
4702
+ setActive(snapshot);
4703
+ setMenu({
4704
+ snapshot,
4705
+ x,
4706
+ y,
4707
+ initialFocus
4708
+ });
4709
+ }, []);
4710
+ (0, react.useEffect)(() => subscribeConfig(() => {
4711
+ dismissMenu();
4712
+ refresh();
4713
+ }), [dismissMenu, refresh]);
4714
+ useSelectionWatcher({
4715
+ toolbarRef,
4716
+ commentWrapRef,
4717
+ phaseRef,
4718
+ touch,
4719
+ dismissMenu,
4720
+ refresh,
4721
+ flushComment,
4722
+ hide,
4723
+ openMenu
4724
+ });
2884
4725
  (0, react.useEffect)(() => {
2885
- let scheduled = false;
2886
- const schedule = () => {
2887
- if (scheduled) return;
2888
- scheduled = true;
2889
- window.setTimeout(() => {
2890
- scheduled = false;
2891
- refresh();
2892
- }, 0);
2893
- };
2894
- let dragging = false;
2895
- const onPointerDown = (event) => {
2896
- const target = event.target;
2897
- if (!(target instanceof Node)) return;
2898
- const inToolbar = toolbarRef.current?.contains(target) === true;
2899
- const inComment = commentWrapRef.current?.contains(target) === true;
2900
- if (inToolbar || inComment) return;
2901
- dragging = true;
2902
- if (phaseRef.current === "comment") {
2903
- flushComment();
2904
- hide();
2905
- return;
2906
- }
2907
- const selection = window.getSelection();
2908
- if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
2909
- schedule();
2910
- return;
4726
+ if (touch) return;
4727
+ const bridge = {
4728
+ state: () => {
4729
+ if (phaseRef.current !== "idle") return "busy";
4730
+ return readSelectionSnapshot(toolbarRef.current) ? "ready" : "no-selection";
4731
+ },
4732
+ open: () => {
4733
+ if (phaseRef.current !== "idle") return;
4734
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4735
+ if (!snapshot) return;
4736
+ const rect = selectionFirstLineRect();
4737
+ if (!rect) return;
4738
+ openMenu(snapshot, rect.left, rect.bottom, "first");
4739
+ },
4740
+ add: () => {
4741
+ if (phaseRef.current !== "idle") return;
4742
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4743
+ if (!snapshot) return;
4744
+ beginComment(snapshot);
2911
4745
  }
2912
- hide();
2913
- };
2914
- const onPointerUp = () => {
2915
- dragging = false;
2916
- schedule();
2917
4746
  };
2918
- const onSelectionChange = () => {
2919
- if (dragging) return;
2920
- schedule();
2921
- };
2922
- const onScroll = (event) => {
2923
- const target = event.target;
2924
- if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
2925
- schedule();
2926
- };
2927
- document.addEventListener("pointerdown", onPointerDown, true);
2928
- document.addEventListener("pointerup", onPointerUp, true);
2929
- document.addEventListener("keyup", schedule, true);
2930
- document.addEventListener("selectionchange", onSelectionChange);
2931
- window.addEventListener("resize", schedule);
2932
- document.addEventListener("scroll", onScroll, true);
4747
+ runtime.selection = bridge;
2933
4748
  return () => {
2934
- document.removeEventListener("pointerdown", onPointerDown, true);
2935
- document.removeEventListener("pointerup", onPointerUp, true);
2936
- document.removeEventListener("keyup", schedule, true);
2937
- document.removeEventListener("selectionchange", onSelectionChange);
2938
- window.removeEventListener("resize", schedule);
2939
- document.removeEventListener("scroll", onScroll, true);
4749
+ if (runtime.selection === bridge) delete runtime.selection;
2940
4750
  };
2941
4751
  }, [
2942
- flushComment,
2943
- hide,
2944
- refresh
4752
+ beginComment,
4753
+ openMenu,
4754
+ touch
2945
4755
  ]);
2946
- const placeCommentPop = (0, react.useCallback)(() => {
2947
- const snapshot = activeRef.current;
2948
- const layer = snapshot?.layer;
2949
- const toolbar = toolbarRef.current;
2950
- const pop = commentWrapRef.current;
2951
- if (!snapshot || !layer || !toolbar || !pop) return;
2952
- const bounds = layer.bounds();
2953
- const toolbarW = toolbar.offsetWidth;
2954
- const toolbarH = toolbar.offsetHeight;
2955
- const home = layer.fromContent(snapshot.left, snapshot.top);
2956
- const anchor = {
2957
- left: home.left - toolbarW / 2,
2958
- top: home.top - (snapshot.below ? 0 : toolbarH),
2959
- width: toolbarW,
2960
- height: toolbarH
2961
- };
2962
- pop.style.display = "flex";
2963
- pop.style.visibility = "hidden";
2964
- const panel = {
2965
- width: pop.offsetWidth || 320,
2966
- height: pop.offsetHeight || 180
2967
- };
2968
- pop.style.visibility = "";
2969
- const gap = 8;
2970
- const margin = 8;
2971
- const belowTop = layer.fromContent(0, snapshot.selectionBottom).top + gap;
2972
- const aboveTop = anchor.top - panel.height - gap;
2973
- const top = belowTop <= bounds.bottom - margin - panel.height ? belowTop : aboveTop >= bounds.top + margin ? aboveTop : Math.max(bounds.top + margin, Math.min(belowTop, bounds.bottom - margin - panel.height));
2974
- const half = panel.width / 2 + 8;
2975
- const min = bounds.left + half;
2976
- const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
2977
- const at = layer.toContent(center, top);
2978
- pop.style.top = `${at.top}px`;
2979
- pop.style.left = `${at.left}px`;
2980
- const sideGap = 6;
2981
- const popLeftView = layer.fromContent(at.left, at.top).left - panel.width / 2;
2982
- const popTopView = top;
2983
- let leftView = popLeftView;
2984
- let topView = popTopView - sideGap - toolbarH;
2985
- if (topView < bounds.top + margin) topView = popTopView + panel.height + sideGap;
2986
- leftView = Math.max(bounds.left + margin, Math.min(leftView, bounds.right - margin - toolbarW));
2987
- topView = Math.max(bounds.top + margin, Math.min(topView, bounds.bottom - margin - toolbarH));
2988
- const toolbarAt = layer.toContent(leftView, topView);
2989
- toolbar.style.left = `${toolbarAt.left}px`;
2990
- toolbar.style.top = `${toolbarAt.top}px`;
2991
- }, []);
4756
+ const placeCommentPop = useCommentPlacement({
4757
+ touch,
4758
+ phase,
4759
+ active,
4760
+ toolbarRef,
4761
+ commentWrapRef
4762
+ });
4763
+ const layer = active?.layer;
2992
4764
  (0, react.useLayoutEffect)(() => {
2993
- if (phase === "comment") placeCommentPop();
4765
+ if (!layer || touch) return;
4766
+ layer.setAboveComposer(phase === "comment");
4767
+ return () => layer.setAboveComposer(false);
2994
4768
  }, [
2995
- active,
4769
+ layer,
2996
4770
  phase,
2997
- placeCommentPop
4771
+ touch
2998
4772
  ]);
2999
4773
  (0, react.useEffect)(() => {
3000
- if (phase === "comment") commentRef.current?.focus();
3001
- }, [phase]);
3002
- (0, react.useEffect)(() => {
3003
- const onResize = () => placeCommentPop();
3004
- window.addEventListener("resize", onResize);
3005
- return () => window.removeEventListener("resize", onResize);
3006
- }, [placeCommentPop]);
4774
+ if (phase === "comment" && !touch) commentRef.current?.focus();
4775
+ }, [phase, touch]);
3007
4776
  (0, react.useLayoutEffect)(() => {
3008
4777
  if (phase !== "comment") return;
3009
4778
  const area = commentRef.current;
3010
4779
  if (!area) return;
3011
4780
  area.style.height = "auto";
3012
4781
  area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
3013
- placeCommentPop();
4782
+ if (!touch) placeCommentPop();
3014
4783
  }, [
3015
4784
  phase,
3016
4785
  comment,
3017
- placeCommentPop
4786
+ placeCommentPop,
4787
+ touch
3018
4788
  ]);
3019
4789
  const handleToolbarPointerDown = (0, react.useCallback)((event) => {
3020
4790
  event.preventDefault();
3021
4791
  event.stopPropagation();
3022
4792
  }, []);
3023
- const handleCopy = (0, react.useCallback)(async () => {
3024
- const snapshot = activeRef.current;
3025
- if (!snapshot) return;
4793
+ /** Copy a latched quote; a failure leaves the caller's surface in place. */
4794
+ const copySnapshot = (0, react.useCallback)(async (snapshot) => {
3026
4795
  try {
3027
4796
  await copyText(snapshot.text);
3028
4797
  clearNativeSelection();
@@ -3031,18 +4800,14 @@ function SelectionToolbar({ sessionId }) {
3031
4800
  setStatus(t("toolbar.copyFailed"));
3032
4801
  }
3033
4802
  }, [hide]);
3034
- /**
3035
- * "Add to task" is the commit: the quote is saved immediately, with or
3036
- * without a comment. The popover only edits that saved entry, so dismissing
3037
- * it (outside click, new selection) can never undo the annotation.
3038
- */
4803
+ const handleCopy = (0, react.useCallback)(() => {
4804
+ const snapshot = activeRef.current;
4805
+ if (snapshot) copySnapshot(snapshot);
4806
+ }, [copySnapshot]);
3039
4807
  const openComment = (0, react.useCallback)(() => {
3040
- if (!activeRef.current) return;
3041
- pendingIdRef.current = saveSelection();
3042
- setPhase("comment");
3043
- setComment("");
3044
- setStatus(null);
3045
- }, [saveSelection]);
4808
+ const snapshot = activeRef.current;
4809
+ if (snapshot) beginComment(snapshot);
4810
+ }, [beginComment]);
3046
4811
  /**
3047
4812
  * Hand the selected link to the system browser.
3048
4813
  *
@@ -3052,137 +4817,840 @@ function SelectionToolbar({ sessionId }) {
3052
4817
  * arbitrary URLs there is what wedges it). Only http(s) selections get this
3053
4818
  * button — every other scheme is refused by that handler.
3054
4819
  */
3055
- const handleOpenLink = (0, react.useCallback)(() => {
3056
- const snapshot = activeRef.current;
3057
- const url = snapshot ? linkTarget(snapshot.text, snapshot.anchorHref) : null;
4820
+ const openLinkSnapshot = (0, react.useCallback)((snapshot) => {
4821
+ const url = linkTarget(snapshot.text, snapshot.anchorHref);
3058
4822
  if (!url) return;
3059
4823
  window.open(url, "_blank", "noopener,noreferrer");
3060
4824
  clearNativeSelection();
3061
4825
  hide();
3062
4826
  }, [hide]);
4827
+ const handleOpenLink = (0, react.useCallback)(() => {
4828
+ const snapshot = activeRef.current;
4829
+ if (snapshot) openLinkSnapshot(snapshot);
4830
+ }, [openLinkSnapshot]);
4831
+ /** Dismissal without an action hands control back to the normal toolbar rules. */
4832
+ const closeMenu = (0, react.useCallback)(() => {
4833
+ if (!dismissMenu()) return;
4834
+ refresh();
4835
+ }, [dismissMenu, refresh]);
4836
+ const handleMenuAction = (0, react.useCallback)((action) => {
4837
+ const current = menuRef.current;
4838
+ if (!current) return;
4839
+ if (action === "add") beginComment(current.snapshot);
4840
+ else if (action === "copy") copySnapshot(current.snapshot);
4841
+ else openLinkSnapshot(current.snapshot);
4842
+ }, [
4843
+ beginComment,
4844
+ copySnapshot,
4845
+ openLinkSnapshot
4846
+ ]);
3063
4847
  /** Close the optional comment step; the annotation stays saved either way. */
3064
4848
  const finishComment = (0, react.useCallback)((keepComment) => {
3065
4849
  if (keepComment) flushComment();
3066
4850
  else pendingIdRef.current = null;
3067
4851
  clearNativeSelection();
3068
4852
  hide();
3069
- window.setTimeout(focusComposer, 16);
4853
+ if (!touch) window.setTimeout(focusComposer, 16);
4854
+ }, [
4855
+ flushComment,
4856
+ hide,
4857
+ touch
4858
+ ]);
4859
+ /** Dismiss the sheet without discarding what was typed (desktop outside-click). */
4860
+ const closeCommentSheet = (0, react.useCallback)(() => {
4861
+ flushComment();
4862
+ hide();
3070
4863
  }, [flushComment, hide]);
4864
+ const handleCommentKeyDown = (0, react.useCallback)((event) => {
4865
+ if (event.key === "Escape") {
4866
+ event.preventDefault();
4867
+ pendingIdRef.current = null;
4868
+ hide();
4869
+ return;
4870
+ }
4871
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
4872
+ event.preventDefault();
4873
+ finishComment(true);
4874
+ }
4875
+ }, [finishComment, hide]);
3071
4876
  if (!active) return null;
3072
4877
  const toolbarStyle = {
3073
4878
  left: active.left,
3074
4879
  top: active.top
3075
4880
  };
3076
- const link = linkTarget(active.text, active.anchorHref);
3077
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3078
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3079
- ref: toolbarRef,
3080
- className: cx(A.row, styles.toolbar),
3081
- style: phase === "comment" ? void 0 : toolbarStyle,
3082
- role: "toolbar",
3083
- "aria-label": t("toolbar.aria"),
3084
- "data-dsq-toolbar": "true",
3085
- "data-anchor": phase === "comment" ? "popover-top" : active.below ? "below" : "above",
3086
- onPointerDown: handleToolbarPointerDown,
3087
- children: phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3088
- className: cx(A.inlineRow, styles.toolbarInner),
3089
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3090
- style: {
3091
- padding: "0 8px",
3092
- fontSize: 13,
3093
- opacity: .8,
3094
- whiteSpace: "nowrap"
3095
- },
3096
- children: t("toolbar.commentHint")
3097
- })
3098
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3099
- className: cx(A.inlineRow, styles.toolbarInner),
3100
- children: [
3101
- link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3102
- type: "button",
3103
- className: cx(A.plain, A.inlineRow, styles.button),
3104
- onClick: handleOpenLink,
3105
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3106
- name: "external-link",
3107
- size: 14
3108
- }), t("toolbar.openLink")]
3109
- }) : null,
3110
- link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3111
- className: styles.divider,
3112
- "aria-hidden": "true"
3113
- }) : null,
3114
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3115
- type: "button",
3116
- className: cx(A.plain, A.inlineRow, styles.button),
3117
- onClick: () => void handleCopy(),
3118
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3119
- name: "copy",
3120
- size: 14
3121
- }), t("toolbar.copy")]
3122
- }),
3123
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3124
- className: styles.divider,
3125
- "aria-hidden": "true"
3126
- }),
3127
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3128
- type: "button",
3129
- className: cx(A.plain, A.inlineRow, styles.button),
3130
- onClick: openComment,
3131
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3132
- name: "plus",
3133
- size: 14
3134
- }), t("toolbar.addToTask")]
4881
+ const actions = [
4882
+ ...linkTarget(active.text, active.anchorHref) ? [{
4883
+ key: "open",
4884
+ icon: "external-link",
4885
+ label: t("toolbar.openLink"),
4886
+ onClick: handleOpenLink
4887
+ }] : [],
4888
+ {
4889
+ key: "copy",
4890
+ icon: "copy",
4891
+ label: t("toolbar.copy"),
4892
+ onClick: handleCopy
4893
+ },
4894
+ {
4895
+ key: "add",
4896
+ icon: "plus",
4897
+ label: t("toolbar.addToTask"),
4898
+ onClick: openComment
4899
+ }
4900
+ ];
4901
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolbarView, {
4902
+ touch,
4903
+ phase,
4904
+ active,
4905
+ status,
4906
+ viewport,
4907
+ actions,
4908
+ comment,
4909
+ toolbarStyle,
4910
+ toolbarRef,
4911
+ commentWrapRef,
4912
+ commentRef,
4913
+ menuOpen: menu !== null,
4914
+ onToolbarPointerDown: handleToolbarPointerDown,
4915
+ onCommentChange: setComment,
4916
+ onCommentKeyDown: handleCommentKeyDown,
4917
+ onFinishComment: finishComment,
4918
+ onCloseSheet: closeCommentSheet
4919
+ }), menu ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectionMenu, {
4920
+ snapshot: menu.snapshot,
4921
+ x: menu.x,
4922
+ y: menu.y,
4923
+ initialFocus: menu.initialFocus,
4924
+ onAction: handleMenuAction,
4925
+ onClose: closeMenu
4926
+ }) : null] });
4927
+ }
4928
+ //#endregion
4929
+ //#region src/client/shortcut-menu.ts
4930
+ const SELECTION_MENU_COMMAND_ID = "select-quote.openMenu";
4931
+ const ADD_TO_TASK_COMMAND_ID = "select-quote.addToTask";
4932
+ /**
4933
+ * Defaults per device profile.
4934
+ *
4935
+ * The two commands never share a binding. Web Linux admits only
4936
+ * `primary+Slash`, `primary+shift+Comma` and `primary+shift+Period`, and the
4937
+ * Host owns `primary+Slash` for its shortcut panel, so the menu takes comma and
4938
+ * add-to-task takes period. Web macOS/Windows accept two-modifier combinations;
4939
+ * theirs avoid the browser's own menu keys. Desktop Windows/Linux also take
4940
+ * period for add-to-task: `Ctrl+Alt+L` is AltGr+L on several layouts, and on
4941
+ * Windows the shell dispatches configured shortcuts before input regions.
4942
+ */
4943
+ const SELECTION_MENU_DEFAULTS = Object.freeze({
4944
+ "desktop:macos": Object.freeze({
4945
+ code: "KeyL",
4946
+ modifiers: Object.freeze(["primary"])
4947
+ }),
4948
+ "desktop:windows": Object.freeze({
4949
+ code: "KeyL",
4950
+ modifiers: Object.freeze(["primary", "shift"])
4951
+ }),
4952
+ "desktop:linux": Object.freeze({
4953
+ code: "KeyL",
4954
+ modifiers: Object.freeze(["primary", "shift"])
4955
+ }),
4956
+ "web:macos": Object.freeze({
4957
+ code: "KeyL",
4958
+ modifiers: Object.freeze(["primary", "shift"])
4959
+ }),
4960
+ "web:windows": Object.freeze({
4961
+ code: "KeyL",
4962
+ modifiers: Object.freeze(["primary", "shift"])
4963
+ }),
4964
+ "web:linux": Object.freeze({
4965
+ code: "Comma",
4966
+ modifiers: Object.freeze(["primary", "shift"])
4967
+ })
4968
+ });
4969
+ const ADD_TO_TASK_DEFAULTS = Object.freeze({
4970
+ "desktop:macos": Object.freeze({
4971
+ code: "KeyL",
4972
+ modifiers: Object.freeze(["primary", "shift"])
4973
+ }),
4974
+ "desktop:windows": Object.freeze({
4975
+ code: "Period",
4976
+ modifiers: Object.freeze(["primary", "shift"])
4977
+ }),
4978
+ "desktop:linux": Object.freeze({
4979
+ code: "Period",
4980
+ modifiers: Object.freeze(["primary", "shift"])
4981
+ }),
4982
+ "web:macos": Object.freeze({
4983
+ code: "KeyL",
4984
+ modifiers: Object.freeze(["primary", "alt"])
4985
+ }),
4986
+ "web:windows": Object.freeze({
4987
+ code: "Period",
4988
+ modifiers: Object.freeze(["primary", "shift"])
4989
+ }),
4990
+ "web:linux": Object.freeze({
4991
+ code: "Period",
4992
+ modifiers: Object.freeze(["primary", "shift"])
4993
+ })
4994
+ });
4995
+ function resolveSelectionAction(action) {
4996
+ const bridge = runtime.selection;
4997
+ if (!bridge) return {
4998
+ status: "blocked",
4999
+ reason: t("menu.noSelection")
5000
+ };
5001
+ const state = bridge.state();
5002
+ if (state === "busy") return {
5003
+ status: "blocked",
5004
+ reason: t("menu.busy")
5005
+ };
5006
+ if (state === "no-selection") return {
5007
+ status: "blocked",
5008
+ reason: t("menu.noSelection")
5009
+ };
5010
+ return {
5011
+ status: "handled",
5012
+ run: () => bridge[action]()
5013
+ };
5014
+ }
5015
+ function selectionMenuCommand() {
5016
+ return {
5017
+ id: SELECTION_MENU_COMMAND_ID,
5018
+ label: () => t("menu.shortcutLabel"),
5019
+ aliases: ["selection menu", "quote menu"],
5020
+ defaults: SELECTION_MENU_DEFAULTS,
5021
+ regions: ["page"],
5022
+ modals: [],
5023
+ resolve: () => resolveSelectionAction("open")
5024
+ };
5025
+ }
5026
+ function addToTaskCommand() {
5027
+ return {
5028
+ id: ADD_TO_TASK_COMMAND_ID,
5029
+ label: () => t("menu.addShortcutLabel"),
5030
+ aliases: ["add to task", "annotate selection"],
5031
+ defaults: ADD_TO_TASK_DEFAULTS,
5032
+ regions: ["page"],
5033
+ modals: [],
5034
+ resolve: () => resolveSelectionAction("add")
5035
+ };
5036
+ }
5037
+ let shortcutRows = [];
5038
+ const shortcutListeners = /* @__PURE__ */ new Set();
5039
+ function getShortcutRows() {
5040
+ return shortcutRows;
5041
+ }
5042
+ function subscribeShortcutRows(listener) {
5043
+ shortcutListeners.add(listener);
5044
+ return () => {
5045
+ shortcutListeners.delete(listener);
5046
+ };
5047
+ }
5048
+ function publishShortcutRows(next) {
5049
+ if (next.length === shortcutRows.length && next.every((row, index) => {
5050
+ const previous = shortcutRows[index];
5051
+ return previous.id === row.id && previous.label === row.label && previous.enabled === row.enabled && previous.keys.length === row.keys.length && previous.keys.every((key, keyIndex) => key === row.keys[keyIndex]);
5052
+ })) return;
5053
+ shortcutRows = next;
5054
+ for (const listener of [...shortcutListeners]) listener();
5055
+ }
5056
+ /**
5057
+ * Mirror the two live catalog rows. The Host drops a command from the catalog
5058
+ * while it is unregistered, and marks a rebound conflict; the settings page
5059
+ * reads this instead of restating the defaults.
5060
+ */
5061
+ function publishShortcutCatalog(ctx) {
5062
+ const catalog = ctx.shortcuts.catalog.getSnapshot();
5063
+ const next = [];
5064
+ for (const id of [SELECTION_MENU_COMMAND_ID, ADD_TO_TASK_COMMAND_ID]) {
5065
+ const row = catalog.find((candidate) => candidate.id === id);
5066
+ if (!row) continue;
5067
+ next.push({
5068
+ id,
5069
+ label: row.label,
5070
+ keys: row.keys,
5071
+ enabled: row.issue === null && row.conflicts.length === 0
5072
+ });
5073
+ }
5074
+ publishShortcutRows(next);
5075
+ }
5076
+ /** Keep both commands registered exactly while the setting is on. */
5077
+ function installSelectionShortcut(ctx) {
5078
+ const disposers = [];
5079
+ const unregisterAll = () => {
5080
+ while (disposers.length > 0) disposers.pop()?.();
5081
+ };
5082
+ const sync = () => {
5083
+ unregisterAll();
5084
+ if (!getConfig().shortcut) return;
5085
+ for (const command of [selectionMenuCommand(), addToTaskCommand()]) try {
5086
+ disposers.push(ctx.shortcuts.register(command));
5087
+ } catch (error) {
5088
+ console.warn(`[dsh-select-quote] shortcut registration failed for ${command.id}:`, error);
5089
+ }
5090
+ };
5091
+ ctx.effect(() => {
5092
+ const syncCatalog = () => publishShortcutCatalog(ctx);
5093
+ syncCatalog();
5094
+ const offCatalog = ctx.shortcuts.catalog.subscribe(syncCatalog);
5095
+ sync();
5096
+ const off = subscribeConfig(sync);
5097
+ return () => {
5098
+ off();
5099
+ offCatalog();
5100
+ unregisterAll();
5101
+ publishShortcutRows([]);
5102
+ };
5103
+ }, "dsh-select-quote: shortcut commands");
5104
+ }
5105
+ //#endregion
5106
+ //#region src/client/settings/ConfigRow.tsx
5107
+ const CHOICES = [
5108
+ "auto",
5109
+ "contextmenu",
5110
+ "both"
5111
+ ];
5112
+ function ConfigRow({ view, form, t: slotTranslate }) {
5113
+ useLocale();
5114
+ if (view !== "page" || form === void 0) return null;
5115
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigSection, {
5116
+ form,
5117
+ slotTranslate
5118
+ });
5119
+ }
5120
+ /**
5121
+ * The bundle config entry gets no `form` prop from the Host (unlike a row
5122
+ * page), so it subscribes to the row's form controller itself. The controller
5123
+ * owns write ordering and revisions; `set` is the one-field convenience.
5124
+ */
5125
+ function ConfigSection({ form, slotTranslate }) {
5126
+ const subscribe = (0, react.useCallback)((listener) => form.subscribe(listener), [form]);
5127
+ const getSnapshot = (0, react.useCallback)(() => form.getSnapshot(), [form]);
5128
+ const current = normalizeConfig((0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot).value);
5129
+ const shortcuts = (0, react.useSyncExternalStore)(subscribeShortcutRows, getShortcutRows, getShortcutRows);
5130
+ const t$1 = slotTranslate ?? t;
5131
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5132
+ className: styles.settings,
5133
+ children: [
5134
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5135
+ className: styles.settingsSection,
5136
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5137
+ className: styles.settingsCopy,
5138
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5139
+ className: styles.settingsLabel,
5140
+ children: t$1("settings.trigger")
5141
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5142
+ className: styles.settingsHint,
5143
+ children: t$1("settings.triggerHint")
5144
+ })]
5145
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5146
+ className: styles.seg,
5147
+ role: "radiogroup",
5148
+ "aria-label": t$1("settings.trigger"),
5149
+ children: CHOICES.map((mode) => {
5150
+ const active = mode === current.triggerMode;
5151
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5152
+ className: styles.segOption,
5153
+ "data-active": active,
5154
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5155
+ type: "radio",
5156
+ className: styles.srOnly,
5157
+ name: "dsq-trigger-mode",
5158
+ value: mode,
5159
+ checked: active,
5160
+ onChange: () => {
5161
+ if (active) return;
5162
+ form.set("triggerMode", mode);
5163
+ }
5164
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t$1(`settings.trigger.${mode}`) })]
5165
+ }, mode);
3135
5166
  })
3136
- ]
3137
- })
3138
- }),
3139
- phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3140
- ref: commentWrapRef,
3141
- className: cx(A.col, styles.commentPop),
3142
- role: "dialog",
3143
- "aria-label": t("toolbar.commentDialogAria"),
3144
- onPointerDown: handleToolbarPointerDown,
3145
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3146
- ref: commentRef,
3147
- className: cx(A.box, styles.commentInput),
3148
- placeholder: t("common.commentPlaceholder"),
3149
- value: comment,
3150
- onChange: (e) => setComment(e.target.value),
3151
- onKeyDown: (e) => {
3152
- if (e.key === "Escape") {
3153
- e.preventDefault();
3154
- pendingIdRef.current = null;
3155
- hide();
3156
- return;
3157
- }
3158
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3159
- e.preventDefault();
3160
- finishComment(true);
3161
- }
3162
- }
3163
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3164
- className: styles.commentActions,
3165
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3166
- type: "button",
3167
- className: cx(A.plain, A.inlineRow, styles.button),
3168
- onClick: () => finishComment(false),
3169
- children: t("toolbar.skipComment")
3170
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3171
- type: "button",
3172
- className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3173
- disabled: !comment.trim(),
3174
- onClick: () => finishComment(true),
3175
- children: t("toolbar.addComment")
3176
5167
  })]
3177
- })]
3178
- }) : null,
3179
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3180
- className: styles.status,
3181
- style: toolbarStyle,
3182
- role: "status",
3183
- children: status
3184
- }) : null
3185
- ] }), active.layer.host);
5168
+ }),
5169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5170
+ className: styles.settingsDivider,
5171
+ "aria-hidden": "true"
5172
+ }),
5173
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5174
+ className: styles.settingsSwitchRow,
5175
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5176
+ className: styles.settingsCopy,
5177
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5178
+ className: styles.settingsLabel,
5179
+ children: t$1("settings.shortcut")
5180
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5181
+ className: styles.settingsHint,
5182
+ children: t$1("settings.shortcutHint")
5183
+ })]
5184
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5185
+ className: styles.switch,
5186
+ "data-on": current.shortcut,
5187
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5188
+ type: "checkbox",
5189
+ className: styles.srOnly,
5190
+ "aria-label": t$1("settings.shortcut"),
5191
+ checked: current.shortcut,
5192
+ onChange: (event) => {
5193
+ const next = event.target.checked;
5194
+ if (next === current.shortcut) return;
5195
+ form.set("shortcut", next);
5196
+ }
5197
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5198
+ className: styles.switchTrack,
5199
+ "aria-hidden": "true"
5200
+ })]
5201
+ })]
5202
+ }),
5203
+ current.shortcut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5204
+ className: styles.settingsShortcuts,
5205
+ "aria-label": t$1("settings.shortcutListAria"),
5206
+ children: shortcuts.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
5207
+ className: styles.settingsHint,
5208
+ children: t$1("settings.shortcutUnavailable")
5209
+ }) : shortcuts.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5210
+ className: styles.settingsShortcutRow,
5211
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5212
+ className: styles.settingsShortcutName,
5213
+ children: row.label
5214
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5215
+ className: styles.settingsShortcutKeys,
5216
+ children: row.enabled ? row.keys.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
5217
+ className: styles.keycap,
5218
+ children: row.keys.join("")
5219
+ }) : t$1("settings.shortcutUnbound") : t$1("settings.shortcutConflict")
5220
+ })]
5221
+ }, row.id))
5222
+ }) : null
5223
+ ]
5224
+ });
5225
+ }
5226
+ //#endregion
5227
+ //#region src/shared/identity.ts
5228
+ /**
5229
+ * Names the Loader and the settings layer agree on.
5230
+ *
5231
+ * The row id lives in `cordis.patch.yml` (and the local `dev.patch.yml`); the
5232
+ * Host config form is keyed by it, so renaming either without this constant
5233
+ * would silently orphan the form. The bundle config section is keyed by the
5234
+ * package name instead.
5235
+ */
5236
+ const PACKAGE_NAME = "dsh-select-quote";
5237
+ const ROW_ID = "select-quote";
5238
+ //#endregion
5239
+ //#region src/client/transcript/quote-registry.ts
5240
+ /**
5241
+ * Quotes rendered for a user message, keyed by that message's flow key.
5242
+ *
5243
+ * The durable annotation data lives in the user turn, but the chip that refers
5244
+ * to it lives in the assistant reply. The chip decorator walks the DOM; this
5245
+ * registry is how it resolves an ordinal back to the original text and comment
5246
+ * without re-parsing the protocol or exposing JSON in the DOM.
5247
+ */
5248
+ const quotesByOwner = /* @__PURE__ */ new Map();
5249
+ function registerMessageQuotes(ownerKey, quotes) {
5250
+ quotesByOwner.set(ownerKey, quotes);
5251
+ return () => {
5252
+ if (quotesByOwner.get(ownerKey) === quotes) quotesByOwner.delete(ownerKey);
5253
+ };
5254
+ }
5255
+ function messageQuotes(ownerKey) {
5256
+ return quotesByOwner.get(ownerKey);
5257
+ }
5258
+ //#endregion
5259
+ //#region src/client/dom/chipPreview.ts
5260
+ /**
5261
+ * Hover/focus/click preview for `:dsh-annotation{index="N"}` chips.
5262
+ *
5263
+ * The chip decorator is plain DOM, so the preview is too. One body-level
5264
+ * preview is reused for every chip: `contain: layout` surfaces in the
5265
+ * transcript would otherwise become the containing block of a fixed popover.
5266
+ *
5267
+ * Clicking the chip no longer jumps: it pins the preview, and the preview's
5268
+ * button owns the jump (same contract on touch, where there is no hover).
5269
+ */
5270
+ const HOVER_DELAY = 250;
5271
+ /** Just enough for the pointer to cross the 8px gap into the preview. */
5272
+ const CLOSE_DELAY = 40;
5273
+ const GAP = 8;
5274
+ /** Matches the leaving keyframe; the node is detached after it finishes. */
5275
+ const EXIT_MS = 120;
5276
+ const JUMP_ICON = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M7 7h10v10\"/><path d=\"M7 17 17 7\"/></svg>";
5277
+ let preview = null;
5278
+ let jumpButton = null;
5279
+ let activeChip = null;
5280
+ let activeMark = null;
5281
+ let activeOwnerKey = null;
5282
+ let activeMessageId = null;
5283
+ let activeText = "";
5284
+ let activeIndex = 0;
5285
+ let pinned = false;
5286
+ let showTimer = 0;
5287
+ let hideTimer = 0;
5288
+ let frame = 0;
5289
+ let suppressFocusOpen = false;
5290
+ let observer = null;
5291
+ let onDocPointerDown = null;
5292
+ let onScroll = null;
5293
+ let onResize = null;
5294
+ let leavingBox = null;
5295
+ let leavingTimer = 0;
5296
+ function cancelTimers() {
5297
+ if (showTimer !== 0) window.clearTimeout(showTimer);
5298
+ if (hideTimer !== 0) window.clearTimeout(hideTimer);
5299
+ showTimer = 0;
5300
+ hideTimer = 0;
5301
+ }
5302
+ /** A new preview replaces any panel still animating out. */
5303
+ function flushLeavingPreview() {
5304
+ if (leavingTimer !== 0) {
5305
+ window.clearTimeout(leavingTimer);
5306
+ leavingTimer = 0;
5307
+ }
5308
+ leavingBox?.remove();
5309
+ leavingBox = null;
5310
+ }
5311
+ function animatePreviewOut(box) {
5312
+ flushLeavingPreview();
5313
+ const active = document.activeElement;
5314
+ if (active instanceof HTMLElement && box.contains(active)) active.blur();
5315
+ box.classList.add(styles.chipPreviewLeaving);
5316
+ box.setAttribute("aria-hidden", "true");
5317
+ leavingBox = box;
5318
+ leavingTimer = window.setTimeout(() => {
5319
+ leavingTimer = 0;
5320
+ if (leavingBox === box) leavingBox = null;
5321
+ box.remove();
5322
+ }, EXIT_MS);
5323
+ }
5324
+ /**
5325
+ * The annotated text can sit in an earlier assistant turn while the quotes were
5326
+ * serialized into the user turn that sent them. The mark id is built by
5327
+ * `TranscriptQuoteCard` as `dqsm_<ownerFlowKey>_<zeroBasedIndex>`, so it — not
5328
+ * the mark's physical ancestor — names the message that owns the quotes.
5329
+ */
5330
+ function quotesOwnerOf(mark) {
5331
+ if (!mark) return null;
5332
+ const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
5333
+ const match = /^dqsm_(.+)_\d+$/.exec(id);
5334
+ return match ? match[1] : null;
5335
+ }
5336
+ /** The quote card for the user turn that sent the annotation, before the chip. */
5337
+ function quotesOwnerBefore(chip, index) {
5338
+ const cards = [...document.querySelectorAll(".dsq_tSummary")];
5339
+ for (let i = cards.length - 1; i >= 0; i -= 1) {
5340
+ const card = cards[i];
5341
+ if ((card.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue;
5342
+ const ownerKey = card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
5343
+ if (ownerKey && messageQuotes(ownerKey)?.[index - 1]) return ownerKey;
5344
+ }
5345
+ return null;
5346
+ }
5347
+ function markByOwner(ownerKey, index) {
5348
+ const id = `dqsm_${ownerKey}_${index - 1}`;
5349
+ const marks = [...document.querySelectorAll("[data-dsq-ann-mark]")].filter((mark) => mark.getAttribute("data-dsq-ann-mark") === id);
5350
+ return marks.find((mark) => mark.getAttribute("data-dsq-ann-index") === String(index)) ?? marks[0] ?? null;
5351
+ }
5352
+ function flowItem(messageId) {
5353
+ for (const item of document.querySelectorAll("[data-chat-flow-key]")) if (item.getAttribute("data-chat-flow-key") === messageId) return item;
5354
+ return null;
5355
+ }
5356
+ function quoteCardFor(ownerKey) {
5357
+ for (const card of document.querySelectorAll(".dsq_tSummary")) if (card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") === ownerKey) return card;
5358
+ return null;
5359
+ }
5360
+ function isVisible(el) {
5361
+ if (!el?.isConnected) return false;
5362
+ const rect = el.getBoundingClientRect();
5363
+ return rect.width > 0 && rect.height > 0;
5364
+ }
5365
+ function flashMessage(el) {
5366
+ el.classList.add(styles.annMessageFlash);
5367
+ window.setTimeout(() => el.classList.remove(styles.annMessageFlash), 1400);
5368
+ }
5369
+ function cancelClose() {
5370
+ if (hideTimer !== 0) window.clearTimeout(hideTimer);
5371
+ hideTimer = 0;
5372
+ }
5373
+ function scheduleClose() {
5374
+ cancelClose();
5375
+ if (pinned) return;
5376
+ hideTimer = window.setTimeout(() => closeChipPreview(false), CLOSE_DELAY);
5377
+ }
5378
+ function scheduleShow(chip, index) {
5379
+ cancelTimers();
5380
+ showTimer = window.setTimeout(() => showChipPreview(chip, index, false), HOVER_DELAY);
5381
+ }
5382
+ function scheduleReposition() {
5383
+ if (frame !== 0) return;
5384
+ frame = window.requestAnimationFrame(() => {
5385
+ frame = 0;
5386
+ reposition();
5387
+ });
5388
+ }
5389
+ function reposition() {
5390
+ if (!preview || !activeChip) return;
5391
+ if (!activeChip.isConnected) {
5392
+ closeChipPreview(false);
5393
+ return;
5394
+ }
5395
+ const anchor = activeChip.getBoundingClientRect();
5396
+ if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
5397
+ closeChipPreview(false);
5398
+ return;
5399
+ }
5400
+ const { top, left } = placePopover(anchor, {
5401
+ width: preview.offsetWidth || 320,
5402
+ height: preview.offsetHeight || 180
5403
+ }, "top", GAP);
5404
+ preview.style.top = `${top}px`;
5405
+ preview.style.left = `${left}px`;
5406
+ }
5407
+ function appendLabel(parent, text) {
5408
+ const label = document.createElement("div");
5409
+ label.className = styles.chipPreviewLabel;
5410
+ label.textContent = text;
5411
+ parent.append(label);
5412
+ }
5413
+ function appendText(parent, className, text) {
5414
+ const node = document.createElement("div");
5415
+ node.className = className;
5416
+ node.textContent = text;
5417
+ parent.append(node);
5418
+ }
5419
+ function jumpToMark(event) {
5420
+ const ownerKey = activeOwnerKey;
5421
+ const messageId = activeMessageId;
5422
+ const text = activeText;
5423
+ const index = activeIndex;
5424
+ const keyboard = event.detail === 0;
5425
+ let mark = activeMark?.isConnected ? activeMark : null;
5426
+ if (!mark && ownerKey) mark = markByOwner(ownerKey, index);
5427
+ const card = ownerKey ? quoteCardFor(ownerKey) : null;
5428
+ const ownerItem = ownerKey ? flowItem(ownerKey) : null;
5429
+ const sourceItem = messageId ? flowItem(messageId) : null;
5430
+ closeChipPreview(false);
5431
+ if (isVisible(mark)) {
5432
+ mark.tabIndex = -1;
5433
+ flashAnnotationMark(mark);
5434
+ if (keyboard) window.setTimeout(() => {
5435
+ if (mark.isConnected) mark.focus({ preventScroll: true });
5436
+ }, 450);
5437
+ return;
5438
+ }
5439
+ const fallback = isVisible(card) ? card : ownerItem ?? sourceItem;
5440
+ if (fallback) {
5441
+ fallback.scrollIntoView({
5442
+ block: "center",
5443
+ behavior: "smooth"
5444
+ });
5445
+ flashMessage(fallback);
5446
+ if (keyboard) window.setTimeout(() => {
5447
+ if (!fallback.isConnected) return;
5448
+ const next = fallback.querySelector("button, [tabindex]") ?? fallback;
5449
+ if (next === fallback) fallback.tabIndex = -1;
5450
+ next.focus({ preventScroll: true });
5451
+ }, 450);
5452
+ return;
5453
+ }
5454
+ if (text) scrollToAnnotation(index, text);
5455
+ }
5456
+ function buildPreview(index, quoteText, comment) {
5457
+ const box = document.createElement("div");
5458
+ box.className = cx(A.col, styles.chipPreview);
5459
+ box.setAttribute("data-dsq-chip-preview", "true");
5460
+ box.setAttribute("role", "dialog");
5461
+ box.setAttribute("aria-label", t("chip.previewAria"));
5462
+ const body = document.createElement("div");
5463
+ body.className = styles.chipPreviewBody;
5464
+ body.style.animation = "none";
5465
+ const head = document.createElement("div");
5466
+ head.className = cx(A.row, styles.chipPreviewHead);
5467
+ const title = document.createElement("span");
5468
+ title.className = styles.chipPreviewTitle;
5469
+ title.textContent = t("chip.annotation", { index });
5470
+ jumpButton = document.createElement("button");
5471
+ jumpButton.type = "button";
5472
+ jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5473
+ jumpButton.setAttribute("data-dsq-chip-jump", "true");
5474
+ jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5475
+ jumpButton.title = t("chip.jumpAria");
5476
+ jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5477
+ const jumpLabel = document.createElement("span");
5478
+ jumpLabel.textContent = t("chip.jump");
5479
+ jumpButton.append(jumpLabel);
5480
+ jumpButton.addEventListener("click", jumpToMark);
5481
+ head.append(title, jumpButton);
5482
+ body.append(head);
5483
+ appendLabel(body, t("common.selectedText"));
5484
+ appendText(body, styles.chipPreviewQuote, quoteText);
5485
+ if (comment !== null) {
5486
+ appendLabel(body, t("summary.userComment"));
5487
+ appendText(body, cx(styles.chipPreviewComment, comment === "" && styles.chipPreviewCommentEmpty), comment === "" ? t("summary.noComment") : comment);
5488
+ }
5489
+ box.append(body);
5490
+ box.addEventListener("pointerenter", cancelClose);
5491
+ box.addEventListener("pointerleave", (event) => {
5492
+ if (event.pointerType === "touch") return;
5493
+ scheduleClose();
5494
+ });
5495
+ box.addEventListener("focusout", (event) => {
5496
+ const next = event.relatedTarget;
5497
+ if (next instanceof Node && (box.contains(next) || activeChip?.contains(next))) return;
5498
+ closeChipPreview(false);
5499
+ });
5500
+ box.addEventListener("keydown", (event) => {
5501
+ if (event.key !== "Escape") return;
5502
+ event.preventDefault();
5503
+ event.stopPropagation();
5504
+ closeChipPreview(true);
5505
+ });
5506
+ return box;
5507
+ }
5508
+ function showChipPreview(chip, index, nextPinned) {
5509
+ cancelTimers();
5510
+ if (!chip.isConnected) return;
5511
+ let mark = findAnnotationMarkBefore(index, chip);
5512
+ let ownerKey = quotesOwnerOf(mark) ?? mark?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") ?? null;
5513
+ let quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
5514
+ if (!quote) {
5515
+ const fallbackKey = quotesOwnerBefore(chip, index);
5516
+ if (fallbackKey) {
5517
+ ownerKey = fallbackKey;
5518
+ quote = messageQuotes(fallbackKey)?.[index - 1];
5519
+ }
5520
+ }
5521
+ if (ownerKey) {
5522
+ const exact = markByOwner(ownerKey, index);
5523
+ if (exact) mark = exact;
5524
+ else if (quotesOwnerOf(mark) !== ownerKey) mark = null;
5525
+ }
5526
+ const text = (quote?.text ?? mark?.textContent ?? "").trim();
5527
+ if (text === "") return;
5528
+ if (activeChip === chip && preview) {
5529
+ pinned = pinned || nextPinned;
5530
+ return;
5531
+ }
5532
+ closeChipPreview(false);
5533
+ flushLeavingPreview();
5534
+ activeChip = chip;
5535
+ activeMark = mark;
5536
+ activeOwnerKey = ownerKey;
5537
+ activeMessageId = quote?.messageId ?? null;
5538
+ activeText = text;
5539
+ activeIndex = index;
5540
+ pinned = nextPinned;
5541
+ const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
5542
+ preview = box;
5543
+ box.style.visibility = "hidden";
5544
+ document.body.append(box);
5545
+ const panel = {
5546
+ width: box.offsetWidth || 320,
5547
+ height: box.offsetHeight || 180
5548
+ };
5549
+ const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
5550
+ box.style.top = `${top}px`;
5551
+ box.style.left = `${left}px`;
5552
+ box.style.visibility = "";
5553
+ const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5554
+ if (animated) animated.style.animation = "";
5555
+ onDocPointerDown = (event) => {
5556
+ const target = event.target;
5557
+ if (!(target instanceof Node)) return;
5558
+ if (box.contains(target) || activeChip?.contains(target)) return;
5559
+ closeChipPreview(false);
5560
+ };
5561
+ document.addEventListener("pointerdown", onDocPointerDown, true);
5562
+ onScroll = scheduleReposition;
5563
+ document.addEventListener("scroll", onScroll, true);
5564
+ onResize = scheduleReposition;
5565
+ window.addEventListener("resize", onResize);
5566
+ observer = new MutationObserver(() => {
5567
+ if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5568
+ });
5569
+ observer.observe(document.body, {
5570
+ childList: true,
5571
+ subtree: true
5572
+ });
5573
+ }
5574
+ /** Close the preview. `restoreFocus` is for a deliberate Escape. */
5575
+ function closeChipPreview(restoreFocus) {
5576
+ const box = preview;
5577
+ const chip = activeChip;
5578
+ cancelTimers();
5579
+ if (frame !== 0) {
5580
+ window.cancelAnimationFrame(frame);
5581
+ frame = 0;
5582
+ }
5583
+ if (observer) {
5584
+ observer.disconnect();
5585
+ observer = null;
5586
+ }
5587
+ if (onDocPointerDown) {
5588
+ document.removeEventListener("pointerdown", onDocPointerDown, true);
5589
+ onDocPointerDown = null;
5590
+ }
5591
+ if (onScroll) {
5592
+ document.removeEventListener("scroll", onScroll, true);
5593
+ onScroll = null;
5594
+ }
5595
+ if (onResize) {
5596
+ window.removeEventListener("resize", onResize);
5597
+ onResize = null;
5598
+ }
5599
+ preview = null;
5600
+ jumpButton = null;
5601
+ activeChip = null;
5602
+ activeMark = null;
5603
+ activeOwnerKey = null;
5604
+ activeMessageId = null;
5605
+ activeText = "";
5606
+ activeIndex = 0;
5607
+ pinned = false;
5608
+ if (restoreFocus && chip?.isConnected) {
5609
+ suppressFocusOpen = true;
5610
+ chip.focus({ preventScroll: true });
5611
+ }
5612
+ if (box) animatePreviewOut(box);
5613
+ suppressFocusOpen = false;
5614
+ }
5615
+ /** Bind the preview to a freshly decorated chip. Safe to call twice. */
5616
+ function bindChipPreview(chip, index) {
5617
+ if (chip.dataset.dsqChipPreviewBound === "true") return;
5618
+ chip.dataset.dsqChipPreviewBound = "true";
5619
+ chip.addEventListener("pointerenter", (event) => {
5620
+ if (event.pointerType === "touch") return;
5621
+ if (activeChip === chip && preview) {
5622
+ cancelClose();
5623
+ return;
5624
+ }
5625
+ scheduleShow(chip, index);
5626
+ });
5627
+ chip.addEventListener("pointerleave", (event) => {
5628
+ if (event.pointerType === "touch") return;
5629
+ scheduleClose();
5630
+ });
5631
+ chip.addEventListener("focus", () => {
5632
+ if (suppressFocusOpen) return;
5633
+ showChipPreview(chip, index, true);
5634
+ });
5635
+ chip.addEventListener("blur", (event) => {
5636
+ const next = event.relatedTarget;
5637
+ if (next instanceof Node && (preview?.contains(next) || chip.contains(next))) return;
5638
+ closeChipPreview(false);
5639
+ });
5640
+ chip.addEventListener("click", () => showChipPreview(chip, index, true));
5641
+ chip.addEventListener("keydown", (event) => {
5642
+ if (event.key === "Escape") {
5643
+ if (activeChip !== chip) return;
5644
+ event.preventDefault();
5645
+ closeChipPreview(true);
5646
+ return;
5647
+ }
5648
+ if (event.key === "ArrowDown") {
5649
+ event.preventDefault();
5650
+ showChipPreview(chip, index, true);
5651
+ jumpButton?.focus();
5652
+ }
5653
+ });
3186
5654
  }
3187
5655
  //#endregion
3188
5656
  //#region src/client/dom/decorateAnnotationDirectives.ts
@@ -3197,7 +5665,7 @@ function decorateTextNode(node) {
3197
5665
  if (!value || !value.includes(":dsh-annotation{")) return;
3198
5666
  const parent = node.parentElement;
3199
5667
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
3200
- if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
5668
+ if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
3201
5669
  if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
3202
5670
  const frag = document.createDocumentFragment();
3203
5671
  let last = 0;
@@ -3214,10 +5682,7 @@ function decorateTextNode(node) {
3214
5682
  chip.setAttribute("data-dsq-deco", "true");
3215
5683
  chip.dataset[DECO_KEY] = String(index);
3216
5684
  chip.textContent = t("chip.annotation", { index });
3217
- chip.addEventListener("click", () => {
3218
- const mark = findAnnotationMarkBefore(index, chip);
3219
- if (mark) flashAnnotationMark(mark);
3220
- });
5685
+ bindChipPreview(chip, index);
3221
5686
  frag.append(chip);
3222
5687
  last = m.index + m[0].length;
3223
5688
  }
@@ -3247,6 +5712,7 @@ function decorateAnnotationDirectives(root) {
3247
5712
  * literal, so `decorateTextNode()` returns before it can touch the DOM.
3248
5713
  */
3249
5714
  function refreshDirectiveChips(root) {
5715
+ closeChipPreview(false);
3250
5716
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
3251
5717
  if (!el) return;
3252
5718
  for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
@@ -3279,66 +5745,142 @@ function watchAnnotationDirectives(root) {
3279
5745
  //#endregion
3280
5746
  //#region src/client/dom/stripSubmissionEcho.ts
3281
5747
  /**
3282
- * Strip the annotation protocol from the host's local submission echo.
5748
+ * Strip the annotation protocol from host surfaces that paint the raw draft.
3283
5749
  *
3284
5750
  * On submit the host paints an optimistic bubble (`[data-submission-echo]`)
3285
5751
  * from the composer draft — which, at that instant, is the protocol block the
3286
- * send gesture just folded in. That echo is rendered by the chat flow itself,
3287
- * not by a `conversation.chat.node` registration, so `UserMessageDisplay`
3288
- * never sees it; with a slow model the raw JSON sits on screen until the
3289
- * durable `user/message` event arrives.
5752
+ * send gesture just folded in. A message submitted while a turn is running is
5753
+ * also previewed in the queue dock (`[data-queue-dock]`) from the same wire
5754
+ * content. Both are rendered by the chat flow itself, not by a
5755
+ * `conversation.chat.node` registration, so `UserMessageDisplay` never sees
5756
+ * them; without this, the raw JSON sits on screen until (or unless) a durable
5757
+ * message replaces it.
3290
5758
  *
3291
5759
  * The durable message keeps the block (the model needs it). This is display
3292
- * only: the echo's plain text run is rewritten to the user's own text. Any
3293
- * other DOM shape (text split by session chips, a truncated block) is left
3294
- * untouched rather than rebuilt, so nothing but our own protocol is edited.
5760
+ * only: the echo's protocol run is rewritten in place, and the queue preview's
5761
+ * word-split text container is rebuilt to the user's own text. The echo row
5762
+ * itself is never rebuilt (it may carry session chips); a truncated block is
5763
+ * left untouched, so nothing but our own protocol is edited.
3295
5764
  */
3296
5765
  const ECHO_SELECTOR = "[data-submission-echo]";
3297
- const STRIPPED_ATTR = "data-dsq-echo-stripped";
3298
- /** The deepest run that carries both markers, or null. */
3299
- function protocolRun(row) {
3300
- for (const el of row.querySelectorAll("*")) {
5766
+ const QUEUE_DOCK_SELECTOR = "[data-queue-dock]";
5767
+ const SURFACE_SELECTOR = `${ECHO_SELECTOR}, ${QUEUE_DOCK_SELECTOR}`;
5768
+ function carriesProtocol(text) {
5769
+ return text.includes("<response-annotations>") && text.includes("</response-annotations>");
5770
+ }
5771
+ /** Deepest runs that carry both markers. A queue dock may carry one per row. */
5772
+ function protocolRuns(row) {
5773
+ if (row.children.length === 0) return carriesProtocol(row.textContent ?? "") ? [row] : [];
5774
+ const runs = [];
5775
+ for (const el of Array.from(row.querySelectorAll("*"))) {
3301
5776
  if (el.children.length > 0) continue;
3302
- const text = el.textContent ?? "";
3303
- if (text.includes("<response-annotations>") && text.includes("</response-annotations>")) return el;
5777
+ if (carriesProtocol(el.textContent ?? "")) runs.push(el);
3304
5778
  }
3305
- return null;
5779
+ return runs;
5780
+ }
5781
+ /**
5782
+ * Rewrite one surface's protocol runs in place.
5783
+ *
5784
+ * Idempotent: once stripped a run no longer carries both markers, so a later
5785
+ * sweep (after the host re-renders the surface) is a no-op rather than a
5786
+ * reason to remember the node forever.
5787
+ */
5788
+ function stripEchoRow(row) {
5789
+ let stripped = false;
5790
+ for (const run of protocolRuns(row)) {
5791
+ const original = run.textContent ?? "";
5792
+ const scan = scanAnnotatedMessage(original);
5793
+ if (scan.rest === original) continue;
5794
+ run.textContent = scan.rest;
5795
+ stripped = true;
5796
+ }
5797
+ return stripped;
5798
+ }
5799
+ /**
5800
+ * Innermost text containers whose text carries a complete block.
5801
+ *
5802
+ * Queue previews go through the host's `conversation.user.text` factory, which
5803
+ * splits the draft into per-word spans; the open and close markers therefore
5804
+ * never share a leaf. The innermost ancestor holding both is the preview's
5805
+ * text container — rewriting that one leaves the row's icon and actions alone.
5806
+ */
5807
+ function textContainers(surface) {
5808
+ const all = Array.from(surface.querySelectorAll("*")).filter((el) => el.children.length > 0 && carriesProtocol(el.textContent ?? ""));
5809
+ return all.filter((el) => !all.some((other) => other !== el && el.contains?.(other) === true));
3306
5810
  }
3307
- function strip(row) {
3308
- if (row.hasAttribute(STRIPPED_ATTR)) return;
3309
- const run = protocolRun(row);
3310
- if (run === null) return;
3311
- const scan = scanAnnotatedMessage(run.textContent ?? "");
3312
- if (scan.annotations.length === 0) return;
3313
- run.textContent = scan.rest;
3314
- row.setAttribute(STRIPPED_ATTR, "");
5811
+ /**
5812
+ * Rewrite each queued row's text container in place. Unlike the echo, the
5813
+ * preview is a word-split run with no chips or attachments inside it, so
5814
+ * rebuilding that container is safe; the row itself is never touched.
5815
+ */
5816
+ function stripQueueDock(dock) {
5817
+ let stripped = false;
5818
+ for (const container of textContainers(dock)) {
5819
+ const original = container.textContent ?? "";
5820
+ const scan = scanAnnotatedMessage(original);
5821
+ if (scan.rest === original) continue;
5822
+ container.textContent = scan.rest;
5823
+ stripped = true;
5824
+ }
5825
+ return stripped;
3315
5826
  }
3316
5827
  /**
3317
- * Watch for local submission echoes and rewrite the protocol block out of
3318
- * them. Runs once immediately, then on every DOM insertion.
5828
+ * Watch for host surfaces that paint the composed draft and rewrite the
5829
+ * protocol block out of them. Runs once immediately, then only on mutations
5830
+ * that can affect one of those surfaces.
3319
5831
  */
3320
5832
  function watchSubmissionEcho(root) {
3321
5833
  const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
3322
5834
  if (!scope) return () => {};
5835
+ const stripSurface = (surface) => {
5836
+ const node = surface;
5837
+ if (surface.matches(QUEUE_DOCK_SELECTOR)) stripQueueDock(node);
5838
+ else stripEchoRow(node);
5839
+ };
3323
5840
  const sweep = () => {
3324
- for (const row of scope.querySelectorAll(ECHO_SELECTOR)) strip(row);
5841
+ for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
3325
5842
  };
3326
5843
  sweep();
3327
5844
  if (typeof MutationObserver === "undefined") return () => {};
5845
+ const pending = /* @__PURE__ */ new Set();
3328
5846
  let frame = 0;
3329
- const observer = new MutationObserver(() => {
5847
+ const collect = (records) => {
5848
+ for (const record of records) {
5849
+ if (record.type === "characterData") {
5850
+ const row = record.target.parentElement?.closest(SURFACE_SELECTOR);
5851
+ if (row) pending.add(row);
5852
+ continue;
5853
+ }
5854
+ for (const node of Array.from(record.addedNodes)) {
5855
+ const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
5856
+ if (el === null) continue;
5857
+ const row = el.closest(SURFACE_SELECTOR);
5858
+ if (row) {
5859
+ pending.add(row);
5860
+ continue;
5861
+ }
5862
+ for (const inner of Array.from(el.querySelectorAll(SURFACE_SELECTOR))) pending.add(inner);
5863
+ }
5864
+ }
5865
+ };
5866
+ const observer = new MutationObserver((records) => {
5867
+ collect(records);
5868
+ if (pending.size === 0) return;
3330
5869
  if (frame !== 0) return;
3331
5870
  frame = window.requestAnimationFrame(() => {
3332
5871
  frame = 0;
3333
- sweep();
5872
+ for (const surface of pending) stripSurface(surface);
5873
+ pending.clear();
3334
5874
  });
3335
5875
  });
3336
5876
  observer.observe(scope, {
3337
5877
  childList: true,
3338
- subtree: true
5878
+ subtree: true,
5879
+ characterData: true
3339
5880
  });
3340
5881
  return () => {
3341
5882
  observer.disconnect();
5883
+ pending.clear();
3342
5884
  if (frame !== 0) window.cancelAnimationFrame(frame);
3343
5885
  };
3344
5886
  }
@@ -3370,6 +5912,12 @@ function TranscriptQuoteCard({ node }) {
3370
5912
  endOffset: quote.endOffset
3371
5913
  })));
3372
5914
  }, [quotes]);
5915
+ (0, react.useEffect)(() => {
5916
+ if (quotes.length === 0) return;
5917
+ const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
5918
+ if (!ownerKey) return;
5919
+ return registerMessageQuotes(ownerKey, quotes);
5920
+ }, [quotes]);
3373
5921
  const markIdOf = (0, react.useCallback)((index) => {
3374
5922
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
3375
5923
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
@@ -3438,9 +5986,9 @@ function imageAttachments(raw) {
3438
5986
  * block is removed from the text blocks, everything else (attachments, file
3439
5987
  * cards, reference labels, time, copy, and any plugin that portals into the
3440
5988
  * actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
3441
- * The annotation cards are the only extra DOM, prepended above the host row.
5989
+ * The annotation summary is the only extra DOM, prepended above the host row.
3442
5990
  *
3443
- * Cards must live inside the `user` node view rather than on a
5991
+ * The summary must live inside the `user` node view rather than on a
3444
5992
  * `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
3445
5993
  * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3446
5994
  * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
@@ -3448,10 +5996,10 @@ function imageAttachments(raw) {
3448
5996
  */
3449
5997
  function UserMessageDisplay(props) {
3450
5998
  ensureToolbarStyles();
3451
- const { node, hostUserOf, ...rest } = props;
5999
+ const { node, hostNodeOf, ...rest } = props;
3452
6000
  const raw = contentBlocksToText(node.data?.content);
3453
6001
  const scanned = scanAnnotatedMessage(raw);
3454
- const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
6002
+ const text = displayTextWithoutQuote(scanned.rest).trim();
3455
6003
  const quotes = scanned.annotations;
3456
6004
  const rewritten = quotes.length > 0 || text !== raw.trim();
3457
6005
  const displayNode = (0, react.useMemo)(() => rewritten ? {
@@ -3466,7 +6014,7 @@ function UserMessageDisplay(props) {
3466
6014
  text
3467
6015
  ]);
3468
6016
  const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
3469
- const Host = hostUserOf?.();
6017
+ const Host = hostNodeOf?.();
3470
6018
  if (Host !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Host, {
3471
6019
  ...rest,
3472
6020
  node: displayNode
@@ -3496,6 +6044,27 @@ function UserMessageDisplay(props) {
3496
6044
  })] });
3497
6045
  }
3498
6046
  //#endregion
6047
+ //#region src/client/transcript/host-user.ts
6048
+ const HOST_NODE_NAME = "UserMessageNodeView";
6049
+ /** Component name across memo/forwardRef/plain function shapes. */
6050
+ function componentName(component) {
6051
+ const candidate = component;
6052
+ return candidate?.type?.name ?? candidate?.render?.name ?? candidate?.name ?? candidate?.displayName;
6053
+ }
6054
+ /**
6055
+ * The host view registered for `key`, or undefined when it cannot be
6056
+ * identified safely.
6057
+ *
6058
+ * A renamed host leaves one non-self entry for that key, which is still
6059
+ * almost certainly the host; more than one is ambiguous, so callers should
6060
+ * show the hostless fallback instead of delegating to an arbitrary plugin's
6061
+ * wrapper.
6062
+ */
6063
+ function pickHostNode(entries, self, key) {
6064
+ const candidates = entries.filter((entry) => entry.options?.key === key && entry.component !== void 0 && entry.component !== self);
6065
+ return (candidates.find((entry) => componentName(entry.component) === HOST_NODE_NAME) ?? (candidates.length === 1 ? candidates[0] : void 0))?.component;
6066
+ }
6067
+ //#endregion
3499
6068
  //#region src/client/state/annotation-persistence.ts
3500
6069
  /** localStorage prefix; a session id is appended as `.<sessionId>`. */
3501
6070
  const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
@@ -3652,7 +6221,7 @@ function createAnnotationPersistence() {
3652
6221
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
3653
6222
  * are always observed. Annotation summary stays on the session composer.
3654
6223
  *
3655
- * History cards render **inside** the `user` Chat node view, not on a
6224
+ * History summary renders **inside** the `user` Chat node view, not on a
3656
6225
  * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
3657
6226
  * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3658
6227
  * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
@@ -3685,6 +6254,19 @@ function apply(ctx) {
3685
6254
  subscribe: (listener) => ctx.locale.subscribe(listener)
3686
6255
  });
3687
6256
  ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
6257
+ ctx.inject(["configForms"], (scope) => {
6258
+ scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
6259
+ const form = scope.configForms.get(ROW_ID);
6260
+ scope.effect(() => scope.configForms.whileServed([ROW_ID], () => scope.slots.inject("plugins.bundle.config", () => scope.slots.register({
6261
+ name: "plugins.bundle.config",
6262
+ key: PACKAGE_NAME,
6263
+ locale: LOCALE_NS,
6264
+ inject: () => ({ form })
6265
+ }, ConfigRow))), "dsh-select-quote: settings section");
6266
+ });
6267
+ ctx.inject(["shortcuts"], (scope) => {
6268
+ installSelectionShortcut(scope);
6269
+ });
3688
6270
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
3689
6271
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
3690
6272
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
@@ -3696,7 +6278,20 @@ function apply(ctx) {
3696
6278
  const port = createAnnotationPersistence();
3697
6279
  setAnnotationPersistence(port);
3698
6280
  port.prune();
3699
- return () => setAnnotationPersistence(null);
6281
+ const onStorage = (event) => {
6282
+ if (event.storageArea !== null && event.storageArea !== window.localStorage) return;
6283
+ if (event.key === null) {
6284
+ adoptStorageAnnotations(null);
6285
+ return;
6286
+ }
6287
+ const sessionId = sessionIdOfKey(event.key);
6288
+ if (sessionId !== null) adoptStorageAnnotations(sessionId);
6289
+ };
6290
+ window.addEventListener("storage", onStorage);
6291
+ return () => {
6292
+ window.removeEventListener("storage", onStorage);
6293
+ setAnnotationPersistence(null);
6294
+ };
3700
6295
  }, "dsh-select-quote: annotation persistence");
3701
6296
  ctx.slots.inject("shell.overlay", () => {
3702
6297
  ctx.slots.register({
@@ -3719,28 +6314,30 @@ function apply(ctx) {
3719
6314
  order: 1
3720
6315
  }, SessionBridge);
3721
6316
  });
3722
- const hostUserOf = () => {
3723
- const candidates = (typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : []).filter((entry) => entry.options?.key === "user" && entry.options?.priority !== -10);
3724
- return (candidates.find((entry) => componentName(entry.component) === "UserMessageNodeView") ?? candidates[0])?.component;
6317
+ const hostNodeOf = (key, self) => {
6318
+ return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
3725
6319
  };
3726
- const UserNode = (props) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
3727
- ...props,
3728
- hostUserOf
3729
- });
3730
- ctx.slots.inject("conversation.chat.node", () => {
6320
+ function UserNode(props) {
6321
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
6322
+ ...props,
6323
+ hostNodeOf: () => hostNodeOf("user", UserNode)
6324
+ });
6325
+ }
6326
+ function SteeringNode(props) {
6327
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
6328
+ ...props,
6329
+ hostNodeOf: () => hostNodeOf("steering", SteeringNode)
6330
+ });
6331
+ }
6332
+ for (const [key, View] of [["user", UserNode], ["steering", SteeringNode]]) ctx.slots.inject("conversation.chat.node", () => {
3731
6333
  ctx.slots.register({
3732
6334
  name: "conversation.chat.node",
3733
- key: "user",
6335
+ key,
3734
6336
  priority: -10,
3735
6337
  locale: "chat"
3736
- }, UserNode);
6338
+ }, View);
3737
6339
  });
3738
6340
  }
3739
- /** Component name across memo/forwardRef/plain function shapes. */
3740
- function componentName(component) {
3741
- const candidate = component;
3742
- return candidate?.type?.name ?? candidate?.name ?? candidate?.displayName;
3743
- }
3744
6341
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3745
6342
  function SessionBridge({ sessionId }) {
3746
6343
  bindAnnotationSession(sessionId);