dsh-select-quote 0.3.8 → 0.3.9

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) {
@@ -99,6 +113,7 @@ body {
99
113
  --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
100
114
  --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
101
115
  --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
116
+ --dsq-focus-ring-soft: color-mix(in srgb, var(--dsq-accent) 50%, transparent);
102
117
  --dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
103
118
  --dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
104
119
  --dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
@@ -108,11 +123,14 @@ body {
108
123
  --dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
109
124
  --dsq-menu: var(--dsw-menu-surface-fill, rgba(248, 249, 250, .58));
110
125
  --dsq-menu-backdrop: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
126
+ --dsq-scrim: rgba(0, 0, 0, 0.32);
111
127
  --dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
112
128
  --dsq-accent: var(--dsw-static-blue-500, #3b82f6);
113
129
  --dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
114
130
  --dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
115
131
  --dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
132
+ --dsq-summary-bg: var(--dsw-alias-bg-layer-2, #fff);
133
+ --dsq-mark-hover: var(--dsw-static-blue-75, #e5f0ff);
116
134
  --dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
117
135
  --dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
118
136
  --dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
@@ -170,6 +188,17 @@ ${atomRules}
170
188
  }
171
189
  }
172
190
 
191
+ /* Exit of the chip preview: the same 4px travel, reversed. Transform only, so
192
+ the material layer keeps sampling its backdrop until the node is removed. */
193
+ @keyframes dsq-rise-out {
194
+ from {
195
+ transform: translateY(0);
196
+ }
197
+ to {
198
+ transform: translateY(4px);
199
+ }
200
+ }
201
+
173
202
  /* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
174
203
  --dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
175
204
  画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
@@ -178,7 +207,9 @@ ${atomRules}
178
207
  菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
179
208
  .dsq_toolbar::before,
180
209
  .dsq_commentPop::before,
181
- .dsq_summaryHoverInner::before {
210
+ .dsq_summaryHoverInner::before,
211
+ .dsq_touchBar::before,
212
+ .dsq_chipPreview::before {
182
213
  content: "";
183
214
  position: absolute;
184
215
  inset: 0;
@@ -201,9 +232,15 @@ ${atomRules}
201
232
  整条链才在浮层元素上取值。 */
202
233
  .dsq_toolbar,
203
234
  .dsq_commentPop,
204
- .dsq_summaryHoverInner {
235
+ .dsq_summaryHoverInner,
236
+ .dsq_touchBar,
237
+ .dsq_touchSheet,
238
+ .dsq_chipPreview {
205
239
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
206
240
  --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));
241
+ /* Every surface above takes the same elevation; declaring it once here is
242
+ what keeps the six component blocks free of a copied box-shadow. */
243
+ box-shadow: var(--dsq-shadow);
207
244
  }
208
245
 
209
246
  /* 浮层挂载与层带(2026-10-20 定,改前先读):
@@ -219,8 +256,10 @@ ${atomRules}
219
256
  现在把两件事交回宿主布局:
220
257
  - 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
221
258
  下面,和正文一致;
222
- - host 的 z-index 固定 6(见 floating-layer.ts):低于 composer 的 7,高于
259
+ - host 的 z-index 平时 6(见 floating-layer.ts):低于 composer 的 7,高于
223
260
  内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
261
+ 评论步骤打开期间抬到 8:摘要卡在 composer 座位里(z 7),会伸进滚动区,
262
+ 6 的评论泡会被它盖住;关闭评论即还原,空闲工具条仍不压输入框。
224
263
  host 内部次序:status 3 > toolbar 2 > commentPop 1。
225
264
 
226
265
  摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
@@ -241,8 +280,7 @@ ${atomRules}
241
280
  由它勾勒轮廓,所以这里不要再画 border,否则描边会和环叠成两条线。 */
242
281
  border: 0;
243
282
  border-radius: 12px;
244
- /* 底色与模糊都在上面的材质图层里,这里不再画背景 */
245
- box-shadow: var(--dsq-shadow);
283
+ /* 底色与模糊都在上面的材质图层里,这里不再画背景;elevation 在共享规则里 */
246
284
  pointer-events: auto;
247
285
  /* A/B:scale 入场放在容器上(材质图层归它所有)。关键帧自带 translate(-50%,-100%),
248
286
  所以缩放不会破坏居中。若真实客户端里磨砂在放大期间消失,改回内容层方案。 */
@@ -261,10 +299,9 @@ ${atomRules}
261
299
  animation-name: dsq-toolbar-in-popover-top;
262
300
  }
263
301
 
264
- /* 按钮行:只负责布局;入场动画在容器上(见 .dsq_toolbar 的 A/B 说明)。 */
302
+ /* 按钮行:形状由 A.inlineRow 提供,这里只留间距;入场动画在容器上
303
+ (见 .dsq_toolbar 的 A/B 说明)。 */
265
304
  .dsq_toolbarInner {
266
- display: inline-flex;
267
- align-items: center;
268
305
  gap: 2px;
269
306
  }
270
307
 
@@ -282,11 +319,17 @@ ${atomRules}
282
319
  background: var(--dsq-fill-hover);
283
320
  }
284
321
 
285
- .dsq_buttonAddComment {
322
+ /* Accent-filled actions: comment commit, annotation save, chip jump. Only the
323
+ metrics differ; fill and hover live here once. */
324
+ .dsq_buttonAddComment,
325
+ .dsq_annEditSave,
326
+ .dsq_chipPreviewJump {
286
327
  background: var(--dsq-accent);
287
- color:#fff;
328
+ color: #fff;
288
329
  }
289
- .dsq_buttonAddComment:hover {
330
+ .dsq_buttonAddComment:hover,
331
+ .dsq_annEditSave:hover,
332
+ .dsq_chipPreviewJump:hover {
290
333
  background: var(--dsq-accent-400);
291
334
  }
292
335
 
@@ -306,10 +349,10 @@ ${atomRules}
306
349
  margin: 0 2px;
307
350
  }
308
351
 
309
- .dsq_status {
310
- position: absolute;
311
- z-index: 3;
312
- transform: translate(-50%, 8px);
352
+ /* Toast pill: the desktop status and the touch status share the whole skin;
353
+ only where they anchor differs. */
354
+ .dsq_status,
355
+ .dsq_touchStatus {
313
356
  padding: 4px 10px;
314
357
  border-radius: 999px;
315
358
  background: var(--dsq-bg-toast);
@@ -320,6 +363,12 @@ ${atomRules}
320
363
  white-space: nowrap;
321
364
  }
322
365
 
366
+ .dsq_status {
367
+ position: absolute;
368
+ z-index: 3;
369
+ transform: translate(-50%, 8px);
370
+ }
371
+
323
372
  /* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
324
373
  .dsq_cardStack {
325
374
  position: absolute;
@@ -373,7 +422,7 @@ ${atomRules}
373
422
  padding: 8px 10px;
374
423
  border: 0.5px solid var(--dsq-line);
375
424
  border-radius: 18px;
376
- background: var(--dsq-neutral-50);
425
+ background: var(--dsq-summary-bg);
377
426
  pointer-events: auto;
378
427
  user-select: none;
379
428
  }
@@ -392,7 +441,9 @@ ${atomRules}
392
441
  position: relative;
393
442
  }
394
443
 
395
- .dsq_summaryTitle {
444
+ /* Card titles (composer summary / touch sheet) share one type scale. */
445
+ .dsq_summaryTitle,
446
+ .dsq_touchSheetTitle {
396
447
  font-size: 14px;
397
448
  font-weight: 500;
398
449
  line-height: 20px;
@@ -405,18 +456,37 @@ ${atomRules}
405
456
  color: var(--dsq-label-3);
406
457
  }
407
458
 
459
+ /* Hover wording is wrong on touch; show one line, not both. */
460
+ .dsq_summaryHintTouch {
461
+ display: none;
462
+ }
463
+
464
+ @media (hover: none), (pointer: coarse) {
465
+ .dsq_summaryHintPointer {
466
+ display: none;
467
+ }
468
+ .dsq_summaryHintTouch {
469
+ display: block;
470
+ }
471
+ }
472
+
473
+ /* The hover panel and the chip preview are the same floating width; keeping it
474
+ in one rule means a width tweak lands on both. */
475
+ .dsq_summaryHover,
476
+ .dsq_chipPreview {
477
+ width: min(320px, calc(100vw - 16px));
478
+ }
479
+
408
480
  .dsq_summaryHover {
409
481
  /* \`display: none\` here is the closed state, so this rule must not take the
410
482
  flex atoms — the open class below turns display back on. */
411
483
  display: none;
412
484
  position: fixed;
413
485
  z-index: 14;
414
- width: min(320px, calc(100vw - 16px));
415
486
  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;
487
+ /* No side padding: the visible card must share the summary's left edge. The
488
+ transparent hover bridge is added by place() only on the facing side. */
489
+ padding: 0;
420
490
  background: transparent;
421
491
  pointer-events: auto;
422
492
  opacity: 0;
@@ -432,14 +502,22 @@ ${atomRules}
432
502
  display: flex;
433
503
  }
434
504
 
505
+ /* Keep the closing panel mounted briefly; the list reverses its rise. */
506
+ .dsq_summaryHoverLeaving {
507
+ pointer-events: none;
508
+ }
509
+
510
+ .dsq_summaryHoverLeaving .dsq_summaryList {
511
+ animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
512
+ }
513
+
435
514
  .dsq_summaryHoverInner {
436
515
  position: relative;
437
516
  gap: 4px;
438
517
  padding: 4px;
439
518
  border-radius: 12px;
440
519
  /* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
441
- 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里。 */
442
- box-shadow: var(--dsq-shadow);
520
+ 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
443
521
  }
444
522
 
445
523
  /* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
@@ -457,7 +535,9 @@ ${atomRules}
457
535
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
458
536
  .dsq_toolbar::before,
459
537
  .dsq_commentPop::before,
460
- .dsq_summaryHoverInner::before {
538
+ .dsq_summaryHoverInner::before,
539
+ .dsq_touchBar::before,
540
+ .dsq_chipPreview::before {
461
541
  background: var(--dsq-bg-raised);
462
542
  backdrop-filter: none;
463
543
  -webkit-backdrop-filter: none;
@@ -477,6 +557,9 @@ ${atomRules}
477
557
 
478
558
  .dsq_summaryItemHead {
479
559
  gap: 6px;
560
+ /* Normal mode's action buttons set the row height; keep it in edit mode too
561
+ so the badge and the label never move when the actions disappear. */
562
+ min-height: 26px;
480
563
  }
481
564
 
482
565
  .dsq_summaryItemBadge {
@@ -516,6 +599,9 @@ ${atomRules}
516
599
 
517
600
  .dsq_summaryItemMain {
518
601
  min-width: 0;
602
+ /* The lead-in lives on the shared wrapper, not on the normal-mode button, so
603
+ edit mode has no wrapper to compensate for. */
604
+ padding-top: 2px;
519
605
  }
520
606
 
521
607
  .dsq_summaryItemButton {
@@ -524,7 +610,9 @@ ${atomRules}
524
610
  text-align: left;
525
611
  font: inherit;
526
612
  border-radius: 8px;
527
- padding: 2px 0 0;
613
+ /* No padding: the 2px lead-in is on .dsq_summaryItemMain, and the UA's
614
+ default button padding would shift the quote sideways and down. */
615
+ padding: 0;
528
616
  }
529
617
 
530
618
  .dsq_annAnchor {
@@ -535,18 +623,36 @@ ${atomRules}
535
623
  pointer-events: none;
536
624
  }
537
625
 
538
- .dsq_summaryItemLabel {
626
+ /* Field labels shared by the hover list and the chip preview. */
627
+ .dsq_summaryItemLabel,
628
+ .dsq_chipPreviewLabel {
539
629
  font-size: 11px;
540
630
  line-height: 16px;
541
631
  color: var(--dsq-label-3);
542
632
  }
543
633
 
544
- .dsq_summaryItemText {
634
+ .dsq_summaryItemLabel {
635
+ margin-top: 8px;
636
+ margin-bottom: 4px;
637
+ }
638
+
639
+ /* The header label shares the class but sits in a centered flex row. */
640
+ .dsq_summaryItemHead .dsq_summaryItemLabel {
641
+ margin: 0;
642
+ }
643
+
644
+ /* Quote/comment body type is shared; only the box behavior differs. */
645
+ .dsq_summaryItemText,
646
+ .dsq_chipPreviewQuote,
647
+ .dsq_chipPreviewComment {
545
648
  font-size: 13px;
546
- line-height: 18px;
547
649
  color: var(--dsq-label);
548
650
  white-space: pre-wrap;
549
651
  overflow-wrap: anywhere;
652
+ }
653
+
654
+ .dsq_summaryItemText {
655
+ line-height: 18px;
550
656
  display: -webkit-box;
551
657
  -webkit-line-clamp: 3;
552
658
  -webkit-box-orient: vertical;
@@ -557,24 +663,22 @@ ${atomRules}
557
663
  color: var(--dsq-label-3);
558
664
  }
559
665
 
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;
666
+ /* Inline edit form rendered inside the hover panel item (no extra layer).
667
+ Shares .dsq_summaryItemText with the read view, but shows the full quote
668
+ instead of clamp-reading it. */
669
+ .dsq_summaryItemTextEdit {
670
+ display: block;
567
671
  max-height: 132px;
568
672
  overflow-y: auto;
569
- margin: 2px 0 6px;
673
+ overscroll-behavior: contain;
570
674
  }
571
675
 
572
- .dsq_annEditInput {
676
+ /* Textarea skin shared by the annotation editor and the comment popover; the
677
+ variants below only set min-height, resize and radius. */
678
+ .dsq_annEditInput,
679
+ .dsq_commentInput {
573
680
  width: 100%;
574
- min-height: 72px;
575
- resize: vertical;
576
681
  border: none;
577
- border-radius: 10px;
578
682
  padding: 8px 10px;
579
683
  font: inherit;
580
684
  font-size: 13px;
@@ -584,44 +688,47 @@ ${atomRules}
584
688
  outline: none;
585
689
  }
586
690
 
587
- .dsq_annEditInput::placeholder {
691
+ .dsq_annEditInput::placeholder,
692
+ .dsq_commentInput::placeholder {
588
693
  color: var(--dsq-label-3);
589
694
  }
590
695
 
696
+ .dsq_annEditInput {
697
+ min-height: 72px;
698
+ resize: vertical;
699
+ border-radius: 10px;
700
+ }
701
+
702
+ .dsq_commentActions,
591
703
  .dsq_annEditActions {
592
704
  justify-content: flex-end;
705
+ }
706
+
707
+ .dsq_annEditActions {
593
708
  gap: 8px;
594
709
  margin-top: 4px;
595
710
  }
596
711
 
597
- .dsq_annEditCancel {
598
- color: var(--dsq-label-2);
712
+ /* Footer buttons share type and metrics; the save fill comes from the shared
713
+ accent-action rule above. */
714
+ .dsq_annEditCancel,
715
+ .dsq_annEditSave {
599
716
  font: inherit;
600
717
  font-size: 13px;
601
718
  padding: 4px 12px;
602
719
  border-radius: 10px;
603
720
  }
604
721
 
722
+ .dsq_annEditCancel {
723
+ color: var(--dsq-label-2);
724
+ }
725
+
605
726
  .dsq_annEditCancel:hover {
606
727
  background: var(--dsq-fill-hover);
607
728
  }
608
729
 
609
730
  .dsq_annEditSave {
610
- appearance: none;
611
- border: none;
612
- background: var(--dsq-accent);
613
- color: #fff;
614
- font: inherit;
615
- font-size: 13px;
616
731
  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
732
  }
626
733
 
627
734
  .dsq_annEditSave:disabled {
@@ -640,35 +747,226 @@ ${atomRules}
640
747
  gap: 8px;
641
748
  padding: 8px;
642
749
  border-radius: 16px;
643
- /* 底色与模糊都在材质图层里 */
644
- box-shadow: var(--dsq-shadow);
750
+ /* 底色与模糊都在材质图层里,elevation 在共享规则里 */
645
751
  /* The layer host is pointer-events:none; interactive surfaces opt back in. */
646
752
  pointer-events: auto;
647
753
  }
648
754
 
649
755
  .dsq_commentInput {
650
- width: 100%;
651
756
  min-height: 64px;
652
757
  resize: none;
653
- border: none;
654
758
  border-radius: 8px;
655
- padding: 8px 10px;
656
- font: inherit;
759
+ }
760
+
761
+ .dsq_commentActions {
762
+ gap: 6px;
763
+ }
764
+
765
+ /* Preview for a model-written annotation chip. Body-level fixed (the chat
766
+ surface's contain: layout would otherwise capture it); hover and click
767
+ share this one surface, and the button owns the jump. */
768
+ .dsq_chipPreview {
769
+ position: fixed;
770
+ z-index: 50;
771
+ box-sizing: border-box;
772
+ max-height: min(420px, calc(100vh - 32px));
773
+ padding: 10px;
774
+ border-radius: 14px;
775
+ pointer-events: auto;
776
+ overflow: hidden;
777
+ }
778
+
779
+ /* Same rise as the annotation card's hover list; on the content, not on the
780
+ material layer, so backdrop sampling stays put. */
781
+ .dsq_chipPreviewBody {
782
+ display: flex;
783
+ flex-direction: column;
784
+ gap: 6px;
785
+ animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
786
+ }
787
+
788
+ .dsq_chipPreviewLeaving {
789
+ pointer-events: none;
790
+ }
791
+
792
+ .dsq_chipPreviewLeaving .dsq_chipPreviewBody {
793
+ animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
794
+ }
795
+
796
+ /* Header rows: title on the left, the single action on the right. */
797
+ .dsq_chipPreviewHead,
798
+ .dsq_touchSheetHead {
799
+ justify-content: space-between;
800
+ gap: 8px;
801
+ }
802
+
803
+ .dsq_chipPreviewTitle {
657
804
  font-size: 13px;
658
- line-height: 20px;
805
+ font-weight: 600;
806
+ line-height: 18px;
659
807
  color: var(--dsq-label);
660
- background: var(--dsq-fill-hover);
661
- outline: none;
662
808
  }
663
809
 
664
- .dsq_commentInput::placeholder {
810
+ .dsq_chipPreviewLabel {
811
+ margin-top: 2px;
812
+ }
813
+
814
+ .dsq_chipPreviewQuote,
815
+ .dsq_chipPreviewComment {
816
+ line-height: 19px;
817
+ max-height: 108px;
818
+ overflow-y: auto;
819
+ overscroll-behavior: contain;
820
+ }
821
+
822
+ .dsq_chipPreviewComment {
823
+ color: var(--dsq-label-2);
824
+ }
825
+
826
+ .dsq_chipPreviewCommentEmpty {
665
827
  color: var(--dsq-label-3);
666
828
  }
667
829
 
668
- .dsq_commentActions {
669
- display: flex;
670
- justify-content: flex-end;
671
- gap: 6px;
830
+ .dsq_chipPreviewJump {
831
+ flex: none;
832
+ justify-content: center;
833
+ gap: 4px;
834
+ min-height: 28px;
835
+ padding: 0 9px;
836
+ border-radius: 7px;
837
+ font: inherit;
838
+ font-size: 12px;
839
+ font-weight: 500;
840
+ line-height: 1;
841
+ white-space: nowrap;
842
+ }
843
+
844
+ /* Touch selection chrome. Fixed to the visual viewport and mounted on body:
845
+ the OS selection menu owns the space around the selected text, and the soft
846
+ keyboard owns the bottom, so neither content coordinates nor the scroller's
847
+ clipping can place this correctly. Only rendered on coarse pointers. */
848
+ .dsq_touchBar,
849
+ .dsq_touchSheet {
850
+ position: fixed;
851
+ left: 50%;
852
+ right: auto;
853
+ box-sizing: border-box;
854
+ width: min(520px, calc(100vw - 16px));
855
+ transform: translateX(-50%);
856
+ touch-action: manipulation;
857
+ pointer-events: auto;
858
+ }
859
+
860
+ .dsq_touchBar {
861
+ z-index: 60;
862
+ gap: 4px;
863
+ padding: 6px;
864
+ border-radius: 16px;
865
+ }
866
+
867
+ .dsq_touchAction {
868
+ flex: 1 1 0;
869
+ min-width: 0;
870
+ min-height: 48px;
871
+ align-items: center;
872
+ justify-content: center;
873
+ gap: 3px;
874
+ padding: 6px 4px;
875
+ border-radius: 10px;
876
+ color: var(--dsq-label);
877
+ font: inherit;
878
+ font-size: 11px;
879
+ line-height: 14px;
880
+ }
881
+
882
+ .dsq_touchAction span {
883
+ max-width: 100%;
884
+ overflow: hidden;
885
+ text-overflow: ellipsis;
886
+ white-space: nowrap;
887
+ }
888
+
889
+ .dsq_touchAction:active {
890
+ background: var(--dsq-fill-hover);
891
+ }
892
+
893
+ .dsq_touchScrim {
894
+ position: fixed;
895
+ inset: 0;
896
+ z-index: 61;
897
+ background: var(--dsq-scrim);
898
+ touch-action: none;
899
+ }
900
+
901
+ .dsq_touchSheet {
902
+ z-index: 62;
903
+ gap: 10px;
904
+ padding: 12px;
905
+ border-radius: 20px;
906
+ background: var(--dsq-bg-raised);
907
+ overflow-y: auto;
908
+ overscroll-behavior: contain;
909
+ }
910
+
911
+ .dsq_touchSheetClose {
912
+ flex: none;
913
+ width: 44px;
914
+ height: 44px;
915
+ margin: -8px -8px -8px 0;
916
+ border-radius: 12px;
917
+ color: var(--dsq-label-2);
918
+ }
919
+
920
+ .dsq_touchSheetClose:active {
921
+ background: var(--dsq-fill-hover);
922
+ }
923
+
924
+ /* Keep the selected words visible after the sheet covers the page: on touch the
925
+ reader has no hover to re-orient, so the sheet carries its own quote. */
926
+ .dsq_touchQuote {
927
+ border-left: 2px solid var(--dsq-accent);
928
+ border-radius: 6px;
929
+ padding: 6px 8px;
930
+ background: var(--dsq-fill-hover);
931
+ color: var(--dsq-label-2);
932
+ font-size: 12px;
933
+ line-height: 18px;
934
+ white-space: pre-wrap;
935
+ overflow-wrap: anywhere;
936
+ display: -webkit-box;
937
+ -webkit-line-clamp: 2;
938
+ -webkit-box-orient: vertical;
939
+ overflow: hidden;
940
+ }
941
+
942
+ /* 16px keeps iOS from zooming the page when the field is focused. */
943
+ .dsq_touchComment {
944
+ min-height: 88px;
945
+ font-size: 16px;
946
+ line-height: 22px;
947
+ }
948
+
949
+ .dsq_touchActions {
950
+ gap: 8px;
951
+ }
952
+
953
+ .dsq_touchActions .dsq_button {
954
+ flex: 1;
955
+ min-height: 44px;
956
+ justify-content: center;
957
+ font-size: 14px;
958
+ }
959
+
960
+ .dsq_touchSecondary {
961
+ background: var(--dsq-fill-hover);
962
+ }
963
+
964
+ .dsq_touchStatus {
965
+ position: fixed;
966
+ left: 50%;
967
+ z-index: 63;
968
+ transform: translateX(-50%);
969
+ max-width: calc(100vw - 32px);
672
970
  }
673
971
 
674
972
  /* Transcript summary — the same block as the composer's, right-aligned under
@@ -682,8 +980,12 @@ ${atomRules}
682
980
 
683
981
  .dsq_tSummary .dsq_summary {
684
982
  width: auto;
685
- min-width: 220px;
983
+ min-width: 160px;
686
984
  max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
985
+ /* In the composer the card must clear .dsq_cardStack (12); in the transcript
986
+ it is plain content and must stay under the floating layer's host (6/8),
987
+ otherwise a hovered comment popover is painted beneath it. */
988
+ z-index: 1;
687
989
  }
688
990
 
689
991
  /* Replacement user bubble (quote block stripped from display). */
@@ -780,13 +1082,74 @@ ${atomRules}
780
1082
  }
781
1083
 
782
1084
  .dsq_annMark:hover {
783
- background: var(--dsq-accent-tint-hover);
1085
+ background: var(--dsq-mark-hover);
784
1086
  }
785
1087
 
786
1088
  .dsq_annMarkFlash {
787
1089
  animation: dsq-ann-flash 1.4s ease-out;
788
1090
  }
789
1091
 
1092
+ /* Fallback jump target when the source mark is no longer in the DOM: flash the
1093
+ message that owns it instead of scrolling nowhere. */
1094
+ .dsq_annMessageFlash {
1095
+ animation: dsq-ann-message-flash 1.4s ease-out;
1096
+ }
1097
+
1098
+ @keyframes dsq-ann-message-flash {
1099
+ 0% {
1100
+ box-shadow: inset 0 0 0 2px var(--dsq-accent);
1101
+ }
1102
+ 100% {
1103
+ box-shadow: inset 0 0 0 2px transparent;
1104
+ }
1105
+ }
1106
+
1107
+ /* Keyboard focus is a first-class state for the toolbar, summary and panel. */
1108
+ .dsq_summary:focus-visible,
1109
+ .dsq_button:focus-visible,
1110
+ .dsq_touchAction:focus-visible,
1111
+ .dsq_touchSheetClose:focus-visible,
1112
+ .dsq_chipPreviewJump:focus-visible,
1113
+ .dsq_annMark:focus-visible,
1114
+ .dsq_summaryItemAction:focus-visible,
1115
+ .dsq_summaryItemButton:focus-visible,
1116
+ .dsq_cardClose:focus-visible,
1117
+ .dsq_annEditCancel:focus-visible,
1118
+ .dsq_annEditSave:focus-visible {
1119
+ outline: 2px solid var(--dsq-focus-ring);
1120
+ outline-offset: 2px;
1121
+ }
1122
+
1123
+ .dsq_commentInput:focus-visible,
1124
+ .dsq_annEditInput:focus-visible {
1125
+ /* The host's button fill is monochrome (black on light, white on dark); a
1126
+ half-transparent accent ring reads as focus without looking like a border. */
1127
+ outline: 2px solid var(--dsq-focus-ring-soft);
1128
+ outline-offset: 1px;
1129
+ }
1130
+
1131
+ /* Touch: 44px minimum targets. Icon-only corners keep their visual size and
1132
+ grow an invisible hit area; toolbar actions grow in place. */
1133
+ @media (hover: none), (pointer: coarse) {
1134
+ .dsq_button,
1135
+ .dsq_annEditCancel,
1136
+ .dsq_annEditSave {
1137
+ min-height: 44px;
1138
+ }
1139
+ .dsq_summaryItemAction {
1140
+ width: 44px;
1141
+ height: 44px;
1142
+ }
1143
+ .dsq_cardClose::after {
1144
+ content: "";
1145
+ position: absolute;
1146
+ inset: -12px;
1147
+ }
1148
+ .dsq_chipPreviewJump {
1149
+ min-height: 44px;
1150
+ }
1151
+ }
1152
+
790
1153
  /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
791
1154
  into product-owned DOM. */
792
1155
 
@@ -794,10 +1157,12 @@ ${atomRules}
794
1157
  0% {
795
1158
  background: var(--dsq-mark-color);
796
1159
  color: #fff;
1160
+ box-shadow: 0 0 0 3px var(--dsq-mark-tint);
797
1161
  }
798
1162
  100% {
799
1163
  background: var(--dsq-mark-tint);
800
1164
  color: inherit;
1165
+ box-shadow: 0 0 0 0 transparent;
801
1166
  }
802
1167
  }
803
1168
 
@@ -805,9 +1170,10 @@ ${atomRules}
805
1170
  --dsq-* alias follows it. Only values we own ourselves need restating. */
806
1171
  body[data-ds-dark-theme] .dsq_annMark {
807
1172
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
1173
+ --dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
808
1174
  }
809
1175
 
810
- /* Reserve room for the floating quote cards inside the composer card, so the
1176
+ /* Reserve room for the floating annotation summary inside the composer card, so the
811
1177
  attachment rail and the editor flow below them instead of being covered. */
812
1178
  .dsq_cardPad {
813
1179
  padding-top: var(--dsq-quote-pad, 88px) !important;
@@ -838,7 +1204,10 @@ const styles = {
838
1204
  summaryBody: "dsq_summaryBody",
839
1205
  summaryTitle: "dsq_summaryTitle",
840
1206
  summaryHint: "dsq_summaryHint",
1207
+ summaryHintPointer: "dsq_summaryHintPointer",
1208
+ summaryHintTouch: "dsq_summaryHintTouch",
841
1209
  summaryHover: "dsq_summaryHover",
1210
+ summaryHoverLeaving: "dsq_summaryHoverLeaving",
842
1211
  summaryItem: "dsq_summaryItem",
843
1212
  summaryItemLabel: "dsq_summaryItemLabel",
844
1213
  summaryItemText: "dsq_summaryItemText",
@@ -846,7 +1215,7 @@ const styles = {
846
1215
  summaryItemMain: "dsq_summaryItemMain",
847
1216
  summaryItemHead: "dsq_summaryItemHead",
848
1217
  summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
849
- annEditText: "dsq_annEditText",
1218
+ summaryItemTextEdit: "dsq_summaryItemTextEdit",
850
1219
  annEditInput: "dsq_annEditInput",
851
1220
  annEditActions: "dsq_annEditActions",
852
1221
  annEditCancel: "dsq_annEditCancel",
@@ -861,9 +1230,32 @@ const styles = {
861
1230
  annAnchor: "dsq_annAnchor",
862
1231
  annMark: "dsq_annMark",
863
1232
  annMarkFlash: "dsq_annMarkFlash",
1233
+ annMessageFlash: "dsq_annMessageFlash",
864
1234
  commentPop: "dsq_commentPop",
865
1235
  commentInput: "dsq_commentInput",
866
1236
  commentActions: "dsq_commentActions",
1237
+ chipPreview: "dsq_chipPreview",
1238
+ chipPreviewBody: "dsq_chipPreviewBody",
1239
+ chipPreviewLeaving: "dsq_chipPreviewLeaving",
1240
+ chipPreviewHead: "dsq_chipPreviewHead",
1241
+ chipPreviewTitle: "dsq_chipPreviewTitle",
1242
+ chipPreviewLabel: "dsq_chipPreviewLabel",
1243
+ chipPreviewQuote: "dsq_chipPreviewQuote",
1244
+ chipPreviewComment: "dsq_chipPreviewComment",
1245
+ chipPreviewCommentEmpty: "dsq_chipPreviewCommentEmpty",
1246
+ chipPreviewJump: "dsq_chipPreviewJump",
1247
+ touchBar: "dsq_touchBar",
1248
+ touchAction: "dsq_touchAction",
1249
+ touchScrim: "dsq_touchScrim",
1250
+ touchSheet: "dsq_touchSheet",
1251
+ touchSheetHead: "dsq_touchSheetHead",
1252
+ touchSheetTitle: "dsq_touchSheetTitle",
1253
+ touchSheetClose: "dsq_touchSheetClose",
1254
+ touchQuote: "dsq_touchQuote",
1255
+ touchComment: "dsq_touchComment",
1256
+ touchActions: "dsq_touchActions",
1257
+ touchSecondary: "dsq_touchSecondary",
1258
+ touchStatus: "dsq_touchStatus",
867
1259
  tSummary: "dsq_tSummary",
868
1260
  userRow: "dsq_userRow",
869
1261
  userStack: "dsq_userStack",
@@ -877,7 +1269,7 @@ const styles = {
877
1269
  * it as stable character offsets over the message's own text.
878
1270
  *
879
1271
  * 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
1272
+ * plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
881
1273
  * are transparent — their text still counts — so an offset captured before a
882
1274
  * mark exists stays valid after decoration.
883
1275
  */
@@ -1085,16 +1477,15 @@ function decorateBySearch(item, job) {
1085
1477
  const full = cleanTextOf(item);
1086
1478
  const needle = job.text.trim();
1087
1479
  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);
1480
+ const patterns = [new RegExp(escapeRegExp(needle), "g"), new RegExp(needle.split(/\s+/).map(escapeRegExp).join("\\s*"), "g")];
1481
+ for (const pattern of patterns) for (const match of full.matchAll(pattern)) {
1482
+ const at = match.index ?? 0;
1483
+ const length = match[0].length;
1484
+ if (length === 0) continue;
1485
+ const located = cleanTextRange(item, at, at + length);
1486
+ if (located) {
1487
+ const pieces = overlappingEntries(located.entries, located.from, located.to);
1488
+ if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
1098
1489
  }
1099
1490
  }
1100
1491
  return false;
@@ -1106,6 +1497,19 @@ function decorateJob(item, job) {
1106
1497
  if (decorateAtOffsets(item, job)) return true;
1107
1498
  return decorateBySearch(item, job);
1108
1499
  }
1500
+ /**
1501
+ * Existing marks are kept, but their ordinal is owned by the job: removing an
1502
+ * earlier annotation renumbers the rest without re-wrapping the text. Only the
1503
+ * final fragment carries the badge.
1504
+ */
1505
+ function refreshMarkIndex(item, job) {
1506
+ const marks = Array.from(item.querySelectorAll(`[${MARK_ATTR}="${attrValue(job.markId)}"]`));
1507
+ if (marks.length === 0) return false;
1508
+ const last = marks[marks.length - 1];
1509
+ const next = String(job.index);
1510
+ if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
1511
+ return true;
1512
+ }
1109
1513
  /** Apply every registered job whose message is currently rendered. */
1110
1514
  function applyAnnotationMarks(root) {
1111
1515
  if (jobs.size === 0) return;
@@ -1113,10 +1517,15 @@ function applyAnnotationMarks(root) {
1113
1517
  if (!scope) return;
1114
1518
  for (const job of [...jobs.values()]) {
1115
1519
  const items = flowItems(job.messageId, scope);
1520
+ let decorated = false;
1116
1521
  for (const item of items) {
1117
- if (item.querySelector(`[${MARK_ATTR}="${attrValue(job.markId)}"]`)) break;
1522
+ if (refreshMarkIndex(item, job)) {
1523
+ decorated = true;
1524
+ continue;
1525
+ }
1526
+ if (decorated) continue;
1118
1527
  if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
1119
- if (decorateJob(item, job)) break;
1528
+ if (decorateJob(item, job)) decorated = true;
1120
1529
  }
1121
1530
  }
1122
1531
  }
@@ -1172,6 +1581,7 @@ function watchAnnotationMarks(root) {
1172
1581
  applyAnnotationMarks(el);
1173
1582
  let frame = 0;
1174
1583
  const schedule = () => {
1584
+ if (jobs.size === 0) return;
1175
1585
  if (frame !== 0) return;
1176
1586
  frame = window.requestAnimationFrame(() => {
1177
1587
  frame = 0;
@@ -1390,43 +1800,52 @@ function composeAnnotatedMessage(draft, annotations) {
1390
1800
  const rest = stripDraftMarker(draft).replace(/^\s+/, "");
1391
1801
  if (annotations.length === 0) return rest;
1392
1802
  const payload = annotations.map(toWireItem);
1803
+ const json = JSON.stringify(payload).replace(/</g, "\\u003c");
1393
1804
  const block = [
1394
1805
  PROTOCOL_HEADER,
1395
1806
  "",
1396
1807
  ANNOTATIONS_BLOCK_OPEN,
1397
- JSON.stringify(payload),
1808
+ json,
1398
1809
  ANNOTATIONS_BLOCK_CLOSE
1399
1810
  ].join("\n");
1400
1811
  return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
1401
1812
  }
1402
1813
  /** Remove `# Response annotations:` instructional prose before the JSON block. */
1403
1814
  function stripProtocolHeader(prefix) {
1404
- if (prefix.indexOf("# Response annotations:") >= 0) return "";
1815
+ const marker = prefix.indexOf("# Response annotations:");
1816
+ if (marker >= 0) return prefix.slice(0, marker).replace(/\s+$/, "");
1405
1817
  return prefix.replace(/\s+$/, "");
1406
1818
  }
1407
1819
  function blockParts(text) {
1408
1820
  const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1409
1821
  if (open < 0) return null;
1410
- const close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, open + 22);
1411
- if (close < 0) return null;
1822
+ const payloadStart = open + 22;
1823
+ const firstClose = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, payloadStart);
1824
+ let close = firstClose;
1825
+ while (close >= 0) {
1826
+ const json = text.slice(payloadStart, close).trim();
1827
+ try {
1828
+ return {
1829
+ before: stripProtocolHeader(text.slice(0, open)),
1830
+ after: text.slice(close + 23).replace(/^\s+/, ""),
1831
+ parsed: JSON.parse(json)
1832
+ };
1833
+ } catch {
1834
+ close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, close + 23);
1835
+ }
1836
+ }
1837
+ if (firstClose < 0) return null;
1412
1838
  return {
1413
1839
  before: stripProtocolHeader(text.slice(0, open)),
1414
- after: text.slice(close + 23).replace(/^\s+/, ""),
1415
- json: text.slice(open + 22, close).trim()
1840
+ after: text.slice(firstClose + 23).replace(/^\s+/, "")
1416
1841
  };
1417
1842
  }
1418
1843
  function joinedRest(parts) {
1419
1844
  return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
1420
1845
  }
1421
- /** Wire payload as an array; null when the block is not valid JSON. */
1846
+ /** Wire payload as an array; null when the block is not an array. */
1422
1847
  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;
1848
+ return Array.isArray(parts.parsed) ? parts.parsed : null;
1430
1849
  }
1431
1850
  /** Every source an item carries; falls back to the selected text itself. */
1432
1851
  function sourcesOf(record, body) {
@@ -1453,6 +1872,9 @@ function wireBody(entry) {
1453
1872
  /**
1454
1873
  * Read plugin annotations out of a durable message. Tolerates the JSON block
1455
1874
  * and strips it from `rest` so the bubble can show only the user's question.
1875
+ * A complete block with a malformed or non-array payload yields no
1876
+ * annotations but is still stripped — the transcript never shows protocol
1877
+ * text, valid or not.
1456
1878
  */
1457
1879
  function scanAnnotatedMessage(text) {
1458
1880
  const parts = blockParts(text);
@@ -1460,12 +1882,12 @@ function scanAnnotatedMessage(text) {
1460
1882
  annotations: [],
1461
1883
  rest: text
1462
1884
  };
1885
+ const rest = joinedRest(parts);
1463
1886
  const parsed = parseWire(parts);
1464
1887
  if (parsed === null) return {
1465
1888
  annotations: [],
1466
- rest: text.replace(/^\s+/, "")
1889
+ rest
1467
1890
  };
1468
- const rest = joinedRest(parts);
1469
1891
  const annotations = [];
1470
1892
  for (const entry of parsed) {
1471
1893
  const item = wireBody(entry);
@@ -1686,18 +2108,63 @@ function adoptPersistedAnnotations(sessionId) {
1686
2108
  if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
1687
2109
  notify();
1688
2110
  }
2111
+ /** Same ids in the same order, with the only mutable field (comment) equal. */
2112
+ function sameAnnotations(a, b) {
2113
+ return a.length === b.length && a.every((item, i) => {
2114
+ const other = b[i];
2115
+ return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2116
+ });
2117
+ }
2118
+ /**
2119
+ * Re-read a session after another tab wrote its storage key.
2120
+ *
2121
+ * Storage is the shared source of truth: every local mutation persists
2122
+ * synchronously, so this tab's memory can only be stale relative to it. The
2123
+ * stored array replaces the local one (last writer wins) — merging would
2124
+ * resurrect annotations the other tab already sent and cleared.
2125
+ *
2126
+ * `null` is the `localStorage.clear()` event: reload every partition this tab
2127
+ * holds. Sessions that are neither cached nor active are ignored; binding the
2128
+ * session later reads storage anyway.
2129
+ */
2130
+ function adoptStorageAnnotations(sessionId) {
2131
+ if (!persistence) return;
2132
+ if (sessionId === null) {
2133
+ let changed = false;
2134
+ for (const key of [...bySession.keys()]) {
2135
+ if (key === MEMORY_KEY) continue;
2136
+ const stored = persistence.load(key);
2137
+ const current = bySession.get(key);
2138
+ if (stored.length === 0) {
2139
+ bySession.delete(key);
2140
+ changed = true;
2141
+ } else if (current === void 0 || !sameAnnotations(stored, current)) {
2142
+ bySession.set(key, [...stored]);
2143
+ changed = true;
2144
+ }
2145
+ }
2146
+ if (changed) notify();
2147
+ return;
2148
+ }
2149
+ if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2150
+ const stored = persistence.load(sessionId);
2151
+ if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2152
+ if (stored.length === 0) bySession.delete(sessionId);
2153
+ else bySession.set(sessionId, [...stored]);
2154
+ notify();
2155
+ }
1689
2156
  function annotationTitle(annotation) {
1690
2157
  return previewTitle(annotation.text);
1691
2158
  }
1692
2159
  //#endregion
1693
2160
  //#region src/client/dom/popoverPlacement.ts
1694
- const GAP = 8;
2161
+ const GAP$1 = 8;
1695
2162
  const MARGIN = 8;
1696
2163
  /**
1697
2164
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
1698
2165
  * lacks room. Keeps the panel inside `bounds` (the viewport by default).
1699
2166
  */
1700
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
2167
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
1701
2168
  const box = bounds ?? {
1702
2169
  top: 0,
1703
2170
  left: 0,
@@ -1784,6 +2251,7 @@ const zh = {
1784
2251
  "common.selectedText": "选中的文本",
1785
2252
  "common.commentPlaceholder": "添加可选评论…",
1786
2253
  "common.cancel": "取消",
2254
+ "common.close": "关闭",
1787
2255
  "common.save": "保存",
1788
2256
  "toolbar.aria": "划词操作",
1789
2257
  "toolbar.commentHint": "添加批注…",
@@ -1795,20 +2263,25 @@ const zh = {
1795
2263
  "toolbar.skipComment": "暂不评论",
1796
2264
  "toolbar.addComment": "添加评论",
1797
2265
  "summary.hint": "悬停查看批注",
2266
+ "summary.hintTouch": "点按查看批注",
1798
2267
  "summary.dialogAria": "{count} 条划词批注详情",
1799
2268
  "summary.edit": "编辑批注 {index}",
1800
2269
  "summary.remove": "移除批注 {index}",
1801
2270
  "summary.userComment": "用户评论",
1802
2271
  "summary.noComment": "未添加评论",
1803
- "summary.aria.other": "{count} 条划词批注。悬停查看详情。",
2272
+ "summary.aria.other": "{count} 条划词批注。展开查看详情。",
1804
2273
  "summary.title.other": "{count} 条批注",
1805
2274
  "summary.removeAll": "移除全部划词批注",
1806
- "chip.annotation": "批注 {index}"
2275
+ "chip.annotation": "批注 {index}",
2276
+ "chip.previewAria": "批注预览",
2277
+ "chip.jump": "原文",
2278
+ "chip.jumpAria": "跳转到原文"
1807
2279
  };
1808
2280
  const en = {
1809
2281
  "common.selectedText": "Selected text",
1810
2282
  "common.commentPlaceholder": "Add an optional comment…",
1811
2283
  "common.cancel": "Cancel",
2284
+ "common.close": "Close",
1812
2285
  "common.save": "Save",
1813
2286
  "toolbar.aria": "Selection actions",
1814
2287
  "toolbar.commentHint": "Add annotation…",
@@ -1820,17 +2293,21 @@ const en = {
1820
2293
  "toolbar.skipComment": "Skip comment",
1821
2294
  "toolbar.addComment": "Add comment",
1822
2295
  "summary.hint": "Hover to view annotations",
2296
+ "summary.hintTouch": "Tap to view annotations",
1823
2297
  "summary.dialogAria": "Annotation details: {count}",
1824
2298
  "summary.edit": "Edit annotation {index}",
1825
2299
  "summary.remove": "Remove annotation {index}",
1826
2300
  "summary.userComment": "User comment",
1827
2301
  "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.",
2302
+ "summary.aria.one": "{count} annotation. Open to view details.",
2303
+ "summary.aria.other": "{count} annotations. Open to view details.",
1830
2304
  "summary.title.one": "{count} annotation",
1831
2305
  "summary.title.other": "{count} annotations",
1832
2306
  "summary.removeAll": "Remove all annotations",
1833
- "chip.annotation": "Annotation {index}"
2307
+ "chip.annotation": "Annotation {index}",
2308
+ "chip.previewAria": "Annotation preview",
2309
+ "chip.jump": "Original",
2310
+ "chip.jumpAria": "Jump to original"
1834
2311
  };
1835
2312
  /**
1836
2313
  * Russian is not a shipped host locale: it is added through
@@ -1841,6 +2318,7 @@ const ru = {
1841
2318
  "common.selectedText": "Выделенный текст",
1842
2319
  "common.commentPlaceholder": "Добавить необязательный комментарий…",
1843
2320
  "common.cancel": "Отмена",
2321
+ "common.close": "Закрыть",
1844
2322
  "common.save": "Сохранить",
1845
2323
  "toolbar.aria": "Действия с выделенным текстом",
1846
2324
  "toolbar.commentHint": "Добавить аннотацию…",
@@ -1852,21 +2330,25 @@ const ru = {
1852
2330
  "toolbar.skipComment": "Без комментария",
1853
2331
  "toolbar.addComment": "Добавить комментарий",
1854
2332
  "summary.hint": "Наведите, чтобы увидеть аннотации",
2333
+ "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
1855
2334
  "summary.dialogAria": "Детали аннотаций: {count}",
1856
2335
  "summary.edit": "Изменить аннотацию {index}",
1857
2336
  "summary.remove": "Удалить аннотацию {index}",
1858
2337
  "summary.userComment": "Комментарий пользователя",
1859
2338
  "summary.noComment": "Комментарий не добавлен",
1860
- "summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
1861
- "summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
1862
- "summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1863
- "summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
2339
+ "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2340
+ "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2341
+ "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2342
+ "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
1864
2343
  "summary.title.one": "{count} аннотация",
1865
2344
  "summary.title.few": "{count} аннотации",
1866
2345
  "summary.title.many": "{count} аннотаций",
1867
2346
  "summary.title.other": "{count} аннотаций",
1868
2347
  "summary.removeAll": "Удалить все аннотации",
1869
- "chip.annotation": "Аннотация {index}"
2348
+ "chip.annotation": "Аннотация {index}",
2349
+ "chip.previewAria": "Просмотр аннотации",
2350
+ "chip.jump": "Оригинал",
2351
+ "chip.jumpAria": "Перейти к оригиналу"
1870
2352
  };
1871
2353
  /** Both shipped host locales, required together by the typed `register()`. */
1872
2354
  const SHIPPED_DICTS = Object.freeze({
@@ -1948,10 +2430,18 @@ function useLocale() {
1948
2430
  }
1949
2431
  //#endregion
1950
2432
  //#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;
2433
+ /** Short grace for crossing the 12px bridge; the exit starts right after it. */
2434
+ const CLOSE_DELAY$1 = 40;
1953
2435
  /** After a viewport change, wait this long before position-checking the pointer. */
1954
2436
  const SETTLE_DELAY = 600;
2437
+ /** Transparent strip on the facing side; the pointer crosses it in transit. */
2438
+ const BRIDGE = 12;
2439
+ /** Matches the list's leaving keyframe; the panel unmounts after it. */
2440
+ const EXIT_MS$1 = 120;
2441
+ /** Stable edit target: item id, with an index fallback for id-less callers. */
2442
+ function editKeyOf(item, index) {
2443
+ return item?.id ?? `index:${index}`;
2444
+ }
1955
2445
  /**
1956
2446
  * Qoder-style summary with a placement-aware hover list.
1957
2447
  *
@@ -1971,21 +2461,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1971
2461
  const settleTimerRef = (0, react.useRef)(null);
1972
2462
  const lastPointerRef = (0, react.useRef)(null);
1973
2463
  const editInputRef = (0, react.useRef)(null);
2464
+ /** Set while keyboard focus is being restored after a deliberate close. */
2465
+ const focusGuardRef = (0, react.useRef)(false);
2466
+ /** Set by keyboard open; the effect focuses the panel once it is mounted. */
2467
+ const enterPanelRef = (0, react.useRef)(false);
2468
+ const panelId = (0, react.useId)();
1974
2469
  const [open, setOpen] = (0, react.useState)(false);
2470
+ const [renderPanel, setRenderPanel] = (0, react.useState)(false);
2471
+ const [closingPanel, setClosingPanel] = (0, react.useState)(false);
2472
+ const exitTimerRef = (0, react.useRef)(null);
1975
2473
  const [editing, setEditing] = (0, react.useState)(null);
1976
2474
  const [editDraft, setEditDraft] = (0, react.useState)("");
1977
2475
  const place = (0, react.useCallback)(() => {
1978
2476
  const root = rootRef.current;
1979
2477
  const pop = popRef.current;
1980
2478
  if (!root || !pop) return;
1981
- const { top, left, side } = placePopover(root.getBoundingClientRect(), {
1982
- width: pop.offsetWidth || 300,
1983
- height: pop.offsetHeight || 220
2479
+ const anchor = root.getBoundingClientRect();
2480
+ const inner = pop.firstElementChild instanceof HTMLElement ? pop.firstElementChild : null;
2481
+ const { top, left, side } = placePopover(anchor, {
2482
+ width: inner?.offsetWidth || pop.offsetWidth || 300,
2483
+ height: (inner?.offsetHeight || pop.offsetHeight || 220) + BRIDGE
1984
2484
  }, "top", 0);
1985
2485
  pop.style.top = `${top}px`;
1986
2486
  pop.style.left = `${left}px`;
1987
- pop.style.paddingTop = side === "bottom" ? "12px" : "12px";
1988
- pop.style.paddingBottom = side === "top" ? "12px" : "12px";
2487
+ pop.style.paddingTop = side === "bottom" ? `${BRIDGE}px` : "0";
2488
+ pop.style.paddingBottom = side === "top" ? `${BRIDGE}px` : "0";
1989
2489
  pop.dataset.side = side;
1990
2490
  pop.style.opacity = "1";
1991
2491
  }, []);
@@ -2003,11 +2503,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2003
2503
  cancelClose();
2004
2504
  setOpen(false);
2005
2505
  }, [cancelClose]);
2506
+ /** Keyboard-focusable controls in the open panel, in DOM order. */
2507
+ const focusables = (0, react.useCallback)(() => {
2508
+ const pop = popRef.current;
2509
+ if (!pop) return [];
2510
+ return Array.from(pop.querySelectorAll("button, textarea, [href], [tabindex]:not([tabindex=\"-1\"])")).filter((el) => !el.hasAttribute("disabled") && el.getClientRects().length > 0);
2511
+ }, []);
2512
+ const focusPanel = (0, react.useCallback)((edge = "first") => {
2513
+ const list = focusables();
2514
+ (edge === "first" ? list[0] : list[list.length - 1])?.focus({ preventScroll: true });
2515
+ }, [focusables]);
2516
+ /** Close the panel and put focus back on the summary without re-opening it. */
2517
+ const closeAndRestoreFocus = (0, react.useCallback)(() => {
2518
+ focusGuardRef.current = true;
2519
+ closePop();
2520
+ rootRef.current?.focus({ preventScroll: true });
2521
+ window.setTimeout(() => {
2522
+ focusGuardRef.current = false;
2523
+ }, 0);
2524
+ }, [closePop]);
2525
+ const openAndEnterPanel = (0, react.useCallback)(() => {
2526
+ enterPanelRef.current = true;
2527
+ openPop();
2528
+ }, [openPop]);
2006
2529
  const scheduleClose = (0, react.useCallback)(() => {
2007
2530
  cancelClose();
2008
2531
  if (editing !== null) return;
2009
- closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
2532
+ closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
2010
2533
  }, [cancelClose, editing]);
2534
+ /** Touch pointers emit pointerleave on lift; only hover pointers auto-close. */
2535
+ const onPointerLeave = (0, react.useCallback)((event) => {
2536
+ if (event?.pointerType === "touch") return;
2537
+ scheduleClose();
2538
+ }, [scheduleClose]);
2539
+ const onSummaryPointerUp = (0, react.useCallback)((event) => {
2540
+ if (event.pointerType !== "touch") return;
2541
+ const target = event.target;
2542
+ if (target instanceof Element && target.closest("button, textarea") !== null) return;
2543
+ openPop();
2544
+ }, [openPop]);
2545
+ (0, react.useEffect)(() => {
2546
+ if (!open) return;
2547
+ const onPointerDown = (event) => {
2548
+ const target = event.target;
2549
+ if (!(target instanceof Node)) return;
2550
+ if (rootRef.current?.contains(target) || popRef.current?.contains(target)) return;
2551
+ if (editing !== null) return;
2552
+ setOpen(false);
2553
+ };
2554
+ document.addEventListener("pointerdown", onPointerDown, true);
2555
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
2556
+ }, [open, editing]);
2557
+ (0, react.useLayoutEffect)(() => {
2558
+ if (open) {
2559
+ if (exitTimerRef.current !== null) {
2560
+ window.clearTimeout(exitTimerRef.current);
2561
+ exitTimerRef.current = null;
2562
+ }
2563
+ setClosingPanel(false);
2564
+ setRenderPanel(true);
2565
+ return;
2566
+ }
2567
+ if (!renderPanel) return;
2568
+ setClosingPanel(true);
2569
+ exitTimerRef.current = window.setTimeout(() => {
2570
+ exitTimerRef.current = null;
2571
+ setRenderPanel(false);
2572
+ setClosingPanel(false);
2573
+ }, EXIT_MS$1);
2574
+ return () => {
2575
+ if (exitTimerRef.current !== null) {
2576
+ window.clearTimeout(exitTimerRef.current);
2577
+ exitTimerRef.current = null;
2578
+ }
2579
+ };
2580
+ }, [open, renderPanel]);
2011
2581
  /** True when the last seen pointer sits on the summary or the panel. */
2012
2582
  const pointerInsideCluster = (0, react.useCallback)(() => {
2013
2583
  const point = lastPointerRef.current;
@@ -2030,6 +2600,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2030
2600
  items,
2031
2601
  editing
2032
2602
  ]);
2603
+ (0, react.useLayoutEffect)(() => {
2604
+ if (!open || !enterPanelRef.current) return;
2605
+ enterPanelRef.current = false;
2606
+ focusPanel("first");
2607
+ }, [open, focusPanel]);
2033
2608
  (0, react.useEffect)(() => {
2034
2609
  if (!open) return;
2035
2610
  const track = (event) => {
@@ -2084,31 +2659,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2084
2659
  const onPointerEnter = (0, react.useCallback)(() => {
2085
2660
  openPop();
2086
2661
  }, [openPop]);
2087
- const onPointerLeave = (0, react.useCallback)(() => {
2088
- scheduleClose();
2089
- }, [scheduleClose]);
2090
2662
  const onBlur = (0, react.useCallback)((e) => {
2663
+ if (editing !== null) return;
2091
2664
  const next = e.relatedTarget;
2092
2665
  if (next instanceof Node) {
2093
2666
  if (rootRef.current?.contains(next)) return;
2094
2667
  if (popRef.current?.contains(next)) return;
2095
2668
  }
2096
2669
  closePop();
2097
- }, [closePop]);
2670
+ }, [closePop, editing]);
2671
+ /** Keyboard focus opens the panel; mouse/touch focus does not. */
2672
+ const onSummaryFocus = (0, react.useCallback)(() => {
2673
+ if (focusGuardRef.current) return;
2674
+ if (rootRef.current?.matches(":focus-visible")) openPop();
2675
+ }, [openPop]);
2098
2676
  const onKeyDown = (0, react.useCallback)((e) => {
2099
2677
  if (e.key === "Escape") {
2678
+ e.preventDefault();
2100
2679
  if (editing !== null) setEditing(null);
2101
- else closePop();
2680
+ else closeAndRestoreFocus();
2681
+ return;
2102
2682
  }
2103
- }, [closePop, editing]);
2683
+ if (e.key === "Enter" || e.key === " ") {
2684
+ if (e.target !== e.currentTarget) return;
2685
+ e.preventDefault();
2686
+ if (open) closePop();
2687
+ else openAndEnterPanel();
2688
+ return;
2689
+ }
2690
+ if (e.key === "ArrowDown" && e.target === e.currentTarget) {
2691
+ e.preventDefault();
2692
+ if (open) focusPanel("first");
2693
+ else openAndEnterPanel();
2694
+ return;
2695
+ }
2696
+ if (e.key === "Tab" && open && !e.shiftKey && e.target === e.currentTarget) {
2697
+ e.preventDefault();
2698
+ focusPanel("first");
2699
+ }
2700
+ }, [
2701
+ closeAndRestoreFocus,
2702
+ closePop,
2703
+ editing,
2704
+ focusPanel,
2705
+ open,
2706
+ openAndEnterPanel
2707
+ ]);
2708
+ const onPanelKeyDown = (0, react.useCallback)((e) => {
2709
+ if (e.key === "Escape") {
2710
+ e.preventDefault();
2711
+ e.stopPropagation();
2712
+ if (editing !== null) setEditing(null);
2713
+ else closeAndRestoreFocus();
2714
+ return;
2715
+ }
2716
+ if (e.key === "Tab" || e.key === "ArrowDown" || e.key === "ArrowUp") e.stopPropagation();
2717
+ const list = focusables();
2718
+ if (list.length === 0) return;
2719
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2720
+ const index = active === null ? -1 : list.indexOf(active);
2721
+ if (e.key === "Tab") {
2722
+ if (list.length === 1) {
2723
+ e.preventDefault();
2724
+ list[0].focus();
2725
+ } else if (!e.shiftKey && index === list.length - 1) {
2726
+ e.preventDefault();
2727
+ list[0].focus();
2728
+ } else if (e.shiftKey && index <= 0) {
2729
+ e.preventDefault();
2730
+ list[list.length - 1].focus();
2731
+ }
2732
+ return;
2733
+ }
2734
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
2735
+ if (e.target instanceof HTMLTextAreaElement) return;
2736
+ e.preventDefault();
2737
+ list[(index + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus({ preventScroll: true });
2738
+ }, [
2739
+ closeAndRestoreFocus,
2740
+ editing,
2741
+ focusables
2742
+ ]);
2104
2743
  const startEdit = (0, react.useCallback)((index) => {
2105
- setEditing(index);
2106
- setEditDraft(items[index]?.comment ?? "");
2744
+ const item = items[index];
2745
+ if (!item) return;
2746
+ setEditing(editKeyOf(item, index));
2747
+ setEditDraft(item.comment ?? "");
2107
2748
  }, [items]);
2108
2749
  const commitEdit = (0, react.useCallback)(() => {
2109
2750
  if (editing === null) return;
2110
- const item = items[editing];
2111
- if (item) onItemEdit?.(editing, item, editDraft);
2751
+ const index = items.findIndex((item, i) => editKeyOf(item, i) === editing);
2752
+ const item = items[index];
2753
+ if (item) onItemEdit?.(index, item, editDraft);
2112
2754
  setEditing(null);
2113
2755
  }, [
2114
2756
  editing,
@@ -2116,19 +2758,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2116
2758
  editDraft,
2117
2759
  onItemEdit
2118
2760
  ]);
2119
- const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2761
+ const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2120
2762
  ref: popRef,
2121
- className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
2763
+ id: panelId,
2764
+ className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
2122
2765
  role: "dialog",
2123
2766
  "aria-label": t("summary.dialogAria", { count }),
2124
2767
  onPointerEnter: openPop,
2125
2768
  onPointerLeave,
2769
+ onKeyDown: onPanelKeyDown,
2126
2770
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2127
2771
  className: cx(A.col, styles.summaryHoverInner),
2128
2772
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2129
2773
  className: cx(A.col, styles.summaryList),
2130
2774
  children: items.map((item, i) => {
2131
- const isEditing = editing === i;
2775
+ const itemKey = editKeyOf(item, i);
2776
+ const isEditing = editing === itemKey;
2132
2777
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2133
2778
  className: styles.summaryItem,
2134
2779
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2164,7 +2809,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2164
2809
  className: styles.summaryItemMain,
2165
2810
  children: [
2166
2811
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2167
- className: styles.annEditText,
2812
+ className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
2168
2813
  children: item.text
2169
2814
  }),
2170
2815
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -2180,6 +2825,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2180
2825
  onKeyDown: (e) => {
2181
2826
  if (e.key === "Escape") {
2182
2827
  e.preventDefault();
2828
+ e.stopPropagation();
2183
2829
  setEditing(null);
2184
2830
  return;
2185
2831
  }
@@ -2198,7 +2844,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2198
2844
  children: t("common.cancel")
2199
2845
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2200
2846
  type: "button",
2201
- className: styles.annEditSave,
2847
+ className: cx(A.plain, styles.annEditSave),
2202
2848
  onClick: commitEdit,
2203
2849
  children: t("common.save")
2204
2850
  })]
@@ -2241,9 +2887,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2241
2887
  "data-selection-annotation-summary": "true",
2242
2888
  "aria-label": t("summary.aria", { count }),
2243
2889
  "aria-expanded": open,
2890
+ "aria-haspopup": "dialog",
2891
+ "aria-controls": open ? panelId : void 0,
2244
2892
  onPointerEnter,
2245
2893
  onPointerLeave,
2246
- onFocus: onPointerEnter,
2894
+ onPointerUp: onSummaryPointerUp,
2895
+ onFocus: onSummaryFocus,
2247
2896
  onBlur,
2248
2897
  onKeyDown,
2249
2898
  children: [
@@ -2254,13 +2903,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2254
2903
  }),
2255
2904
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2256
2905
  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
- })]
2906
+ children: [
2907
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2908
+ className: styles.summaryTitle,
2909
+ children: t("summary.title", { count })
2910
+ }),
2911
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2912
+ className: cx(styles.summaryHint, styles.summaryHintPointer),
2913
+ children: t("summary.hint")
2914
+ }),
2915
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2916
+ className: cx(styles.summaryHint, styles.summaryHintTouch),
2917
+ children: t("summary.hintTouch")
2918
+ })
2919
+ ]
2264
2920
  }),
2265
2921
  onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2266
2922
  type: "button",
@@ -2321,8 +2977,7 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef
2321
2977
  onFolded();
2322
2978
  };
2323
2979
  const onKeyDown = (event) => {
2324
- if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
2325
- if (event.isComposing) return;
2980
+ if (!isSubmitKey(event)) return;
2326
2981
  const target = event.target;
2327
2982
  if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
2328
2983
  const card = composerCardOf(target);
@@ -2545,10 +3200,35 @@ const QuoteCard = AnnotationPanel;
2545
3200
  const HOST_ATTR = "data-dsq-layer";
2546
3201
  /** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
2547
3202
  const HOST_Z = 6;
3203
+ /**
3204
+ * Raised while the comment step is open. The composer summary card lives in
3205
+ * the seat (z 7) and can extend over the scroller area; the popover, clipped
3206
+ * to the scroller, would otherwise paint underneath it.
3207
+ */
3208
+ const HOST_Z_ABOVE_COMPOSER = 8;
2548
3209
  const layers = /* @__PURE__ */ new WeakMap();
2549
3210
  /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2550
3211
  const itemLayers = /* @__PURE__ */ new WeakMap();
2551
3212
  let fallback = null;
3213
+ const SEAT_SELECTOR = "[data-composer-seat]";
3214
+ const CARD_SELECTOR = "[data-composer-card]";
3215
+ /**
3216
+ * The composer belonging to `scroller`, or the document's first one when the
3217
+ * scroller has no seat in its ancestor chain.
3218
+ *
3219
+ * The desktop layout puts the sticky seat outside the scroll container; a
3220
+ * document-wide lookup picks the first chat on the page, which is wrong when
3221
+ * more than one conversation is mounted. Walk out from the scroller and take
3222
+ * the first seat inside the nearest ancestor that has one.
3223
+ */
3224
+ function composerSeatFor(scroller) {
3225
+ if (typeof document === "undefined") return null;
3226
+ if (scroller !== null) for (let el = scroller; el !== null && el !== document.body; el = el.parentElement) {
3227
+ const seat = el.querySelector(SEAT_SELECTOR) ?? el.querySelector(CARD_SELECTOR);
3228
+ if (seat !== null) return seat;
3229
+ }
3230
+ return document.querySelector(SEAT_SELECTOR) ?? document.querySelector(CARD_SELECTOR);
3231
+ }
2552
3232
  /**
2553
3233
  * Top edge of the composer seat, in viewport coordinates.
2554
3234
  *
@@ -2558,11 +3238,8 @@ let fallback = null;
2558
3238
  * parks popovers under the input box — the bottom edge must be the composer.
2559
3239
  * In the regular layout the seat sits below the scroller, so it changes nothing.
2560
3240
  */
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;
3241
+ function composerTop(scroller) {
3242
+ return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
2566
3243
  }
2567
3244
  function contentCoords(scroller, left, top) {
2568
3245
  const rect = scroller.getBoundingClientRect();
@@ -2589,7 +3266,7 @@ function layerFor(scroller) {
2589
3266
  const rect = scroller.getBoundingClientRect();
2590
3267
  const top = rect.top + scroller.clientTop;
2591
3268
  const rawBottom = top + scroller.clientHeight;
2592
- const cover = composerTop();
3269
+ const cover = composerTop(scroller);
2593
3270
  const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
2594
3271
  return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
2595
3272
  },
@@ -2600,6 +3277,9 @@ function layerFor(scroller) {
2600
3277
  left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
2601
3278
  top: top + rect.top + scroller.clientTop - scroller.scrollTop
2602
3279
  };
3280
+ },
3281
+ setAboveComposer: (above) => {
3282
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
2603
3283
  }
2604
3284
  };
2605
3285
  layers.set(scroller, layer);
@@ -2616,7 +3296,7 @@ function fallbackLayer() {
2616
3296
  host,
2617
3297
  scroller: document.body,
2618
3298
  bounds: () => {
2619
- const bottom = composerTop() ?? window.innerHeight;
3299
+ const bottom = composerTop(null) ?? window.innerHeight;
2620
3300
  return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
2621
3301
  },
2622
3302
  toContent: (left, top) => ({
@@ -2626,7 +3306,10 @@ function fallbackLayer() {
2626
3306
  fromContent: (left, top) => ({
2627
3307
  left,
2628
3308
  top
2629
- })
3309
+ }),
3310
+ setAboveComposer: (above) => {
3311
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
3312
+ }
2630
3313
  };
2631
3314
  return fallback;
2632
3315
  }
@@ -2811,6 +3494,69 @@ function linkTarget(text, anchorHref) {
2811
3494
  return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
2812
3495
  }
2813
3496
  //#endregion
3497
+ //#region src/client/ui/touch.ts
3498
+ /**
3499
+ * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
3500
+ * criterion means a device never gets the bottom bar with desktop positioning
3501
+ * (or the reverse).
3502
+ */
3503
+ const COARSE_POINTER = "(hover: none), (pointer: coarse)";
3504
+ function coarsePointer() {
3505
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
3506
+ return window.matchMedia(COARSE_POINTER).matches;
3507
+ }
3508
+ function useCoarsePointer() {
3509
+ const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
3510
+ (0, react.useEffect)(() => {
3511
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
3512
+ const query = window.matchMedia(COARSE_POINTER);
3513
+ const onChange = (event) => setCoarse(event.matches);
3514
+ setCoarse(query.matches);
3515
+ query.addEventListener("change", onChange);
3516
+ return () => query.removeEventListener("change", onChange);
3517
+ }, []);
3518
+ return coarse;
3519
+ }
3520
+ function measure() {
3521
+ if (typeof window === "undefined") return {
3522
+ inset: 0,
3523
+ height: 0
3524
+ };
3525
+ const viewport = window.visualViewport;
3526
+ const layout = document.documentElement.clientHeight || window.innerHeight;
3527
+ if (!viewport) return {
3528
+ inset: 0,
3529
+ height: window.innerHeight
3530
+ };
3531
+ return {
3532
+ inset: Math.max(0, Math.round(layout - viewport.height - viewport.offsetTop)),
3533
+ height: Math.round(viewport.height)
3534
+ };
3535
+ }
3536
+ /**
3537
+ * Ride above the soft keyboard. `innerHeight` alone is not enough: iOS keeps the
3538
+ * layout viewport at full height and only shrinks the visual viewport, while
3539
+ * Android resizes the layout viewport. The inset is zero in the Android case
3540
+ * because the fixed element then already sits above the keyboard.
3541
+ */
3542
+ function useVisualViewport(enabled) {
3543
+ const [metrics, setMetrics] = (0, react.useState)(measure);
3544
+ (0, react.useEffect)(() => {
3545
+ if (!enabled || typeof window === "undefined") return;
3546
+ const update = () => setMetrics(measure());
3547
+ update();
3548
+ window.visualViewport?.addEventListener("resize", update);
3549
+ window.visualViewport?.addEventListener("scroll", update);
3550
+ window.addEventListener("resize", update);
3551
+ return () => {
3552
+ window.visualViewport?.removeEventListener("resize", update);
3553
+ window.visualViewport?.removeEventListener("scroll", update);
3554
+ window.removeEventListener("resize", update);
3555
+ };
3556
+ }, [enabled]);
3557
+ return metrics;
3558
+ }
3559
+ //#endregion
2814
3560
  //#region src/client/SelectionToolbar.tsx
2815
3561
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2816
3562
  const COMMENT_MAX_HEIGHT = 220;
@@ -2827,6 +3573,8 @@ function SelectionToolbar({ sessionId }) {
2827
3573
  activeRef.current = active;
2828
3574
  /** Annotation saved when the comment step opened; the box only edits it. */
2829
3575
  const pendingIdRef = (0, react.useRef)(null);
3576
+ const touch = useCoarsePointer();
3577
+ const viewport = useVisualViewport(touch);
2830
3578
  (0, react.useEffect)(() => {
2831
3579
  ensureToolbarStyles();
2832
3580
  if (sessionId) bindAnnotationSession(sessionId);
@@ -2944,6 +3692,7 @@ function SelectionToolbar({ sessionId }) {
2944
3692
  refresh
2945
3693
  ]);
2946
3694
  const placeCommentPop = (0, react.useCallback)(() => {
3695
+ if (touch) return;
2947
3696
  const snapshot = activeRef.current;
2948
3697
  const layer = snapshot?.layer;
2949
3698
  const toolbar = toolbarRef.current;
@@ -2988,33 +3737,46 @@ function SelectionToolbar({ sessionId }) {
2988
3737
  const toolbarAt = layer.toContent(leftView, topView);
2989
3738
  toolbar.style.left = `${toolbarAt.left}px`;
2990
3739
  toolbar.style.top = `${toolbarAt.top}px`;
2991
- }, []);
3740
+ }, [touch]);
2992
3741
  (0, react.useLayoutEffect)(() => {
2993
- if (phase === "comment") placeCommentPop();
3742
+ if (phase === "comment" && !touch) placeCommentPop();
2994
3743
  }, [
2995
3744
  active,
2996
3745
  phase,
2997
- placeCommentPop
3746
+ placeCommentPop,
3747
+ touch
3748
+ ]);
3749
+ const layer = active?.layer;
3750
+ (0, react.useLayoutEffect)(() => {
3751
+ if (!layer || touch) return;
3752
+ layer.setAboveComposer(phase === "comment");
3753
+ return () => layer.setAboveComposer(false);
3754
+ }, [
3755
+ layer,
3756
+ phase,
3757
+ touch
2998
3758
  ]);
2999
3759
  (0, react.useEffect)(() => {
3000
- if (phase === "comment") commentRef.current?.focus();
3001
- }, [phase]);
3760
+ if (phase === "comment" && !touch) commentRef.current?.focus();
3761
+ }, [phase, touch]);
3002
3762
  (0, react.useEffect)(() => {
3763
+ if (touch) return;
3003
3764
  const onResize = () => placeCommentPop();
3004
3765
  window.addEventListener("resize", onResize);
3005
3766
  return () => window.removeEventListener("resize", onResize);
3006
- }, [placeCommentPop]);
3767
+ }, [placeCommentPop, touch]);
3007
3768
  (0, react.useLayoutEffect)(() => {
3008
3769
  if (phase !== "comment") return;
3009
3770
  const area = commentRef.current;
3010
3771
  if (!area) return;
3011
3772
  area.style.height = "auto";
3012
3773
  area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
3013
- placeCommentPop();
3774
+ if (!touch) placeCommentPop();
3014
3775
  }, [
3015
3776
  phase,
3016
3777
  comment,
3017
- placeCommentPop
3778
+ placeCommentPop,
3779
+ touch
3018
3780
  ]);
3019
3781
  const handleToolbarPointerDown = (0, react.useCallback)((event) => {
3020
3782
  event.preventDefault();
@@ -3066,14 +3828,147 @@ function SelectionToolbar({ sessionId }) {
3066
3828
  else pendingIdRef.current = null;
3067
3829
  clearNativeSelection();
3068
3830
  hide();
3069
- window.setTimeout(focusComposer, 16);
3831
+ if (!touch) window.setTimeout(focusComposer, 16);
3832
+ }, [
3833
+ flushComment,
3834
+ hide,
3835
+ touch
3836
+ ]);
3837
+ /** Dismiss the sheet without discarding what was typed (desktop outside-click). */
3838
+ const closeCommentSheet = (0, react.useCallback)(() => {
3839
+ flushComment();
3840
+ hide();
3070
3841
  }, [flushComment, hide]);
3071
3842
  if (!active) return null;
3072
3843
  const toolbarStyle = {
3073
3844
  left: active.left,
3074
3845
  top: active.top
3075
3846
  };
3076
- const link = linkTarget(active.text, active.anchorHref);
3847
+ const actions = [
3848
+ ...linkTarget(active.text, active.anchorHref) ? [{
3849
+ key: "open",
3850
+ icon: "external-link",
3851
+ label: t("toolbar.openLink"),
3852
+ onClick: handleOpenLink
3853
+ }] : [],
3854
+ {
3855
+ key: "copy",
3856
+ icon: "copy",
3857
+ label: t("toolbar.copy"),
3858
+ onClick: () => void handleCopy()
3859
+ },
3860
+ {
3861
+ key: "add",
3862
+ icon: "plus",
3863
+ label: t("toolbar.addToTask"),
3864
+ onClick: openComment
3865
+ }
3866
+ ];
3867
+ const actionRow = (stacked) => actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3868
+ className: styles.divider,
3869
+ "aria-hidden": "true"
3870
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3871
+ type: "button",
3872
+ className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
3873
+ onClick: action.onClick,
3874
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3875
+ name: action.icon,
3876
+ size: stacked ? 18 : 14
3877
+ }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
3878
+ })] }, action.key));
3879
+ const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3880
+ ref: commentRef,
3881
+ className: cx(A.box, styles.commentInput, touch && styles.touchComment),
3882
+ placeholder: t("common.commentPlaceholder"),
3883
+ value: comment,
3884
+ onChange: (e) => setComment(e.target.value),
3885
+ onKeyDown: (e) => {
3886
+ if (e.key === "Escape") {
3887
+ e.preventDefault();
3888
+ pendingIdRef.current = null;
3889
+ hide();
3890
+ return;
3891
+ }
3892
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3893
+ e.preventDefault();
3894
+ finishComment(true);
3895
+ }
3896
+ }
3897
+ });
3898
+ const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3899
+ className: cx(A.row, styles.commentActions, touch && styles.touchActions),
3900
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3901
+ type: "button",
3902
+ className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
3903
+ onClick: () => finishComment(false),
3904
+ children: t("toolbar.skipComment")
3905
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3906
+ type: "button",
3907
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3908
+ disabled: !comment.trim(),
3909
+ onClick: () => finishComment(true),
3910
+ children: t("toolbar.addComment")
3911
+ })]
3912
+ });
3913
+ if (touch) {
3914
+ const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
3915
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3916
+ phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3917
+ ref: toolbarRef,
3918
+ className: cx(A.row, styles.touchBar),
3919
+ style: { bottom },
3920
+ role: "toolbar",
3921
+ "aria-label": t("toolbar.aria"),
3922
+ "data-dsq-toolbar": "true",
3923
+ "data-touch": "true",
3924
+ onPointerDown: handleToolbarPointerDown,
3925
+ children: actionRow(true)
3926
+ }) : null,
3927
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3928
+ className: styles.touchScrim,
3929
+ "aria-hidden": "true"
3930
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3931
+ ref: commentWrapRef,
3932
+ className: cx(A.col, styles.touchSheet),
3933
+ style: {
3934
+ bottom,
3935
+ maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
3936
+ },
3937
+ role: "dialog",
3938
+ "aria-label": t("toolbar.commentDialogAria"),
3939
+ children: [
3940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3941
+ className: cx(A.row, styles.touchSheetHead),
3942
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3943
+ className: styles.touchSheetTitle,
3944
+ children: t("toolbar.commentHint")
3945
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3946
+ type: "button",
3947
+ className: cx(A.plain, A.gridMid, styles.touchSheetClose),
3948
+ "aria-label": t("common.close"),
3949
+ onClick: closeCommentSheet,
3950
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3951
+ name: "x",
3952
+ size: 18
3953
+ })
3954
+ })]
3955
+ }),
3956
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3957
+ className: styles.touchQuote,
3958
+ children: active.text
3959
+ }),
3960
+ commentField,
3961
+ commentActions
3962
+ ]
3963
+ })] }) : null,
3964
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3965
+ className: styles.touchStatus,
3966
+ style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
3967
+ role: "status",
3968
+ children: status
3969
+ }) : null
3970
+ ] }), document.body);
3971
+ }
3077
3972
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3078
3973
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3079
3974
  ref: toolbarRef,
@@ -3095,45 +3990,9 @@ function SelectionToolbar({ sessionId }) {
3095
3990
  },
3096
3991
  children: t("toolbar.commentHint")
3097
3992
  })
3098
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3993
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3099
3994
  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")]
3135
- })
3136
- ]
3995
+ children: actionRow(false)
3137
3996
  })
3138
3997
  }),
3139
3998
  phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -3142,39 +4001,7 @@ function SelectionToolbar({ sessionId }) {
3142
4001
  role: "dialog",
3143
4002
  "aria-label": t("toolbar.commentDialogAria"),
3144
4003
  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
- })]
3177
- })]
4004
+ children: [commentField, commentActions]
3178
4005
  }) : null,
3179
4006
  status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3180
4007
  className: styles.status,
@@ -3185,6 +4012,423 @@ function SelectionToolbar({ sessionId }) {
3185
4012
  ] }), active.layer.host);
3186
4013
  }
3187
4014
  //#endregion
4015
+ //#region src/client/transcript/quote-registry.ts
4016
+ /**
4017
+ * Quotes rendered for a user message, keyed by that message's flow key.
4018
+ *
4019
+ * The durable annotation data lives in the user turn, but the chip that refers
4020
+ * to it lives in the assistant reply. The chip decorator walks the DOM; this
4021
+ * registry is how it resolves an ordinal back to the original text and comment
4022
+ * without re-parsing the protocol or exposing JSON in the DOM.
4023
+ */
4024
+ const quotesByOwner = /* @__PURE__ */ new Map();
4025
+ function registerMessageQuotes(ownerKey, quotes) {
4026
+ quotesByOwner.set(ownerKey, quotes);
4027
+ return () => {
4028
+ if (quotesByOwner.get(ownerKey) === quotes) quotesByOwner.delete(ownerKey);
4029
+ };
4030
+ }
4031
+ function messageQuotes(ownerKey) {
4032
+ return quotesByOwner.get(ownerKey);
4033
+ }
4034
+ //#endregion
4035
+ //#region src/client/dom/chipPreview.ts
4036
+ /**
4037
+ * Hover/focus/click preview for `:dsh-annotation{index="N"}` chips.
4038
+ *
4039
+ * The chip decorator is plain DOM, so the preview is too. One body-level
4040
+ * preview is reused for every chip: `contain: layout` surfaces in the
4041
+ * transcript would otherwise become the containing block of a fixed popover.
4042
+ *
4043
+ * Clicking the chip no longer jumps: it pins the preview, and the preview's
4044
+ * button owns the jump (same contract on touch, where there is no hover).
4045
+ */
4046
+ const HOVER_DELAY = 250;
4047
+ /** Just enough for the pointer to cross the 8px gap into the preview. */
4048
+ const CLOSE_DELAY = 40;
4049
+ const GAP = 8;
4050
+ /** Matches the leaving keyframe; the node is detached after it finishes. */
4051
+ const EXIT_MS = 120;
4052
+ 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>";
4053
+ let preview = null;
4054
+ let jumpButton = null;
4055
+ let activeChip = null;
4056
+ let activeMark = null;
4057
+ let activeOwnerKey = null;
4058
+ let activeMessageId = null;
4059
+ let activeText = "";
4060
+ let activeIndex = 0;
4061
+ let pinned = false;
4062
+ let showTimer = 0;
4063
+ let hideTimer = 0;
4064
+ let frame = 0;
4065
+ let suppressFocusOpen = false;
4066
+ let observer = null;
4067
+ let onDocPointerDown = null;
4068
+ let onScroll = null;
4069
+ let onResize = null;
4070
+ let leavingBox = null;
4071
+ let leavingTimer = 0;
4072
+ function cancelTimers() {
4073
+ if (showTimer !== 0) window.clearTimeout(showTimer);
4074
+ if (hideTimer !== 0) window.clearTimeout(hideTimer);
4075
+ showTimer = 0;
4076
+ hideTimer = 0;
4077
+ }
4078
+ /** A new preview replaces any panel still animating out. */
4079
+ function flushLeavingPreview() {
4080
+ if (leavingTimer !== 0) {
4081
+ window.clearTimeout(leavingTimer);
4082
+ leavingTimer = 0;
4083
+ }
4084
+ leavingBox?.remove();
4085
+ leavingBox = null;
4086
+ }
4087
+ function animatePreviewOut(box) {
4088
+ flushLeavingPreview();
4089
+ const active = document.activeElement;
4090
+ if (active instanceof HTMLElement && box.contains(active)) active.blur();
4091
+ box.classList.add(styles.chipPreviewLeaving);
4092
+ box.setAttribute("aria-hidden", "true");
4093
+ leavingBox = box;
4094
+ leavingTimer = window.setTimeout(() => {
4095
+ leavingTimer = 0;
4096
+ if (leavingBox === box) leavingBox = null;
4097
+ box.remove();
4098
+ }, EXIT_MS);
4099
+ }
4100
+ /**
4101
+ * The annotated text can sit in an earlier assistant turn while the quotes were
4102
+ * serialized into the user turn that sent them. The mark id is built by
4103
+ * `TranscriptQuoteCard` as `dqsm_<ownerFlowKey>_<zeroBasedIndex>`, so it — not
4104
+ * the mark's physical ancestor — names the message that owns the quotes.
4105
+ */
4106
+ function quotesOwnerOf(mark) {
4107
+ if (!mark) return null;
4108
+ const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
4109
+ const match = /^dqsm_(.+)_\d+$/.exec(id);
4110
+ return match ? match[1] : null;
4111
+ }
4112
+ /** The quote card for the user turn that sent the annotation, before the chip. */
4113
+ function quotesOwnerBefore(chip, index) {
4114
+ const cards = [...document.querySelectorAll(".dsq_tSummary")];
4115
+ for (let i = cards.length - 1; i >= 0; i -= 1) {
4116
+ const card = cards[i];
4117
+ if ((card.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue;
4118
+ const ownerKey = card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
4119
+ if (ownerKey && messageQuotes(ownerKey)?.[index - 1]) return ownerKey;
4120
+ }
4121
+ return null;
4122
+ }
4123
+ function markByOwner(ownerKey, index) {
4124
+ const id = `dqsm_${ownerKey}_${index - 1}`;
4125
+ const marks = [...document.querySelectorAll("[data-dsq-ann-mark]")].filter((mark) => mark.getAttribute("data-dsq-ann-mark") === id);
4126
+ return marks.find((mark) => mark.getAttribute("data-dsq-ann-index") === String(index)) ?? marks[0] ?? null;
4127
+ }
4128
+ function flowItem(messageId) {
4129
+ for (const item of document.querySelectorAll("[data-chat-flow-key]")) if (item.getAttribute("data-chat-flow-key") === messageId) return item;
4130
+ return null;
4131
+ }
4132
+ function quoteCardFor(ownerKey) {
4133
+ for (const card of document.querySelectorAll(".dsq_tSummary")) if (card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") === ownerKey) return card;
4134
+ return null;
4135
+ }
4136
+ function isVisible(el) {
4137
+ if (!el?.isConnected) return false;
4138
+ const rect = el.getBoundingClientRect();
4139
+ return rect.width > 0 && rect.height > 0;
4140
+ }
4141
+ function flashMessage(el) {
4142
+ el.classList.add(styles.annMessageFlash);
4143
+ window.setTimeout(() => el.classList.remove(styles.annMessageFlash), 1400);
4144
+ }
4145
+ function cancelClose() {
4146
+ if (hideTimer !== 0) window.clearTimeout(hideTimer);
4147
+ hideTimer = 0;
4148
+ }
4149
+ function scheduleClose() {
4150
+ cancelClose();
4151
+ if (pinned) return;
4152
+ hideTimer = window.setTimeout(() => closeChipPreview(false), CLOSE_DELAY);
4153
+ }
4154
+ function scheduleShow(chip, index) {
4155
+ cancelTimers();
4156
+ showTimer = window.setTimeout(() => showChipPreview(chip, index, false), HOVER_DELAY);
4157
+ }
4158
+ function scheduleReposition() {
4159
+ if (frame !== 0) return;
4160
+ frame = window.requestAnimationFrame(() => {
4161
+ frame = 0;
4162
+ reposition();
4163
+ });
4164
+ }
4165
+ function reposition() {
4166
+ if (!preview || !activeChip) return;
4167
+ if (!activeChip.isConnected) {
4168
+ closeChipPreview(false);
4169
+ return;
4170
+ }
4171
+ const anchor = activeChip.getBoundingClientRect();
4172
+ if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
4173
+ closeChipPreview(false);
4174
+ return;
4175
+ }
4176
+ const { top, left } = placePopover(anchor, {
4177
+ width: preview.offsetWidth || 320,
4178
+ height: preview.offsetHeight || 180
4179
+ }, "top", GAP);
4180
+ preview.style.top = `${top}px`;
4181
+ preview.style.left = `${left}px`;
4182
+ }
4183
+ function appendLabel(parent, text) {
4184
+ const label = document.createElement("div");
4185
+ label.className = styles.chipPreviewLabel;
4186
+ label.textContent = text;
4187
+ parent.append(label);
4188
+ }
4189
+ function appendText(parent, className, text) {
4190
+ const node = document.createElement("div");
4191
+ node.className = className;
4192
+ node.textContent = text;
4193
+ parent.append(node);
4194
+ }
4195
+ function jumpToMark(event) {
4196
+ const ownerKey = activeOwnerKey;
4197
+ const messageId = activeMessageId;
4198
+ const text = activeText;
4199
+ const index = activeIndex;
4200
+ const keyboard = event.detail === 0;
4201
+ let mark = activeMark?.isConnected ? activeMark : null;
4202
+ if (!mark && ownerKey) mark = markByOwner(ownerKey, index);
4203
+ const card = ownerKey ? quoteCardFor(ownerKey) : null;
4204
+ const ownerItem = ownerKey ? flowItem(ownerKey) : null;
4205
+ const sourceItem = messageId ? flowItem(messageId) : null;
4206
+ closeChipPreview(false);
4207
+ if (isVisible(mark)) {
4208
+ mark.tabIndex = -1;
4209
+ flashAnnotationMark(mark);
4210
+ if (keyboard) window.setTimeout(() => {
4211
+ if (mark.isConnected) mark.focus({ preventScroll: true });
4212
+ }, 450);
4213
+ return;
4214
+ }
4215
+ const fallback = isVisible(card) ? card : ownerItem ?? sourceItem;
4216
+ if (fallback) {
4217
+ fallback.scrollIntoView({
4218
+ block: "center",
4219
+ behavior: "smooth"
4220
+ });
4221
+ flashMessage(fallback);
4222
+ if (keyboard) window.setTimeout(() => {
4223
+ if (!fallback.isConnected) return;
4224
+ const next = fallback.querySelector("button, [tabindex]") ?? fallback;
4225
+ if (next === fallback) fallback.tabIndex = -1;
4226
+ next.focus({ preventScroll: true });
4227
+ }, 450);
4228
+ return;
4229
+ }
4230
+ if (text) scrollToAnnotation(index, text);
4231
+ }
4232
+ function buildPreview(index, quoteText, comment) {
4233
+ const box = document.createElement("div");
4234
+ box.className = cx(A.col, styles.chipPreview);
4235
+ box.setAttribute("data-dsq-chip-preview", "true");
4236
+ box.setAttribute("role", "dialog");
4237
+ box.setAttribute("aria-label", t("chip.previewAria"));
4238
+ const body = document.createElement("div");
4239
+ body.className = styles.chipPreviewBody;
4240
+ body.style.animation = "none";
4241
+ const head = document.createElement("div");
4242
+ head.className = cx(A.row, styles.chipPreviewHead);
4243
+ const title = document.createElement("span");
4244
+ title.className = styles.chipPreviewTitle;
4245
+ title.textContent = t("chip.annotation", { index });
4246
+ jumpButton = document.createElement("button");
4247
+ jumpButton.type = "button";
4248
+ jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
4249
+ jumpButton.setAttribute("data-dsq-chip-jump", "true");
4250
+ jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
4251
+ jumpButton.title = t("chip.jumpAria");
4252
+ jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
4253
+ const jumpLabel = document.createElement("span");
4254
+ jumpLabel.textContent = t("chip.jump");
4255
+ jumpButton.append(jumpLabel);
4256
+ jumpButton.addEventListener("click", jumpToMark);
4257
+ head.append(title, jumpButton);
4258
+ body.append(head);
4259
+ appendLabel(body, t("common.selectedText"));
4260
+ appendText(body, styles.chipPreviewQuote, quoteText);
4261
+ if (comment !== null) {
4262
+ appendLabel(body, t("summary.userComment"));
4263
+ appendText(body, cx(styles.chipPreviewComment, comment === "" && styles.chipPreviewCommentEmpty), comment === "" ? t("summary.noComment") : comment);
4264
+ }
4265
+ box.append(body);
4266
+ box.addEventListener("pointerenter", cancelClose);
4267
+ box.addEventListener("pointerleave", (event) => {
4268
+ if (event.pointerType === "touch") return;
4269
+ scheduleClose();
4270
+ });
4271
+ box.addEventListener("focusout", (event) => {
4272
+ const next = event.relatedTarget;
4273
+ if (next instanceof Node && (box.contains(next) || activeChip?.contains(next))) return;
4274
+ closeChipPreview(false);
4275
+ });
4276
+ box.addEventListener("keydown", (event) => {
4277
+ if (event.key !== "Escape") return;
4278
+ event.preventDefault();
4279
+ event.stopPropagation();
4280
+ closeChipPreview(true);
4281
+ });
4282
+ return box;
4283
+ }
4284
+ function showChipPreview(chip, index, nextPinned) {
4285
+ cancelTimers();
4286
+ if (!chip.isConnected) return;
4287
+ let mark = findAnnotationMarkBefore(index, chip);
4288
+ let ownerKey = quotesOwnerOf(mark) ?? mark?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") ?? null;
4289
+ let quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
4290
+ if (!quote) {
4291
+ const fallbackKey = quotesOwnerBefore(chip, index);
4292
+ if (fallbackKey) {
4293
+ ownerKey = fallbackKey;
4294
+ quote = messageQuotes(fallbackKey)?.[index - 1];
4295
+ }
4296
+ }
4297
+ if (ownerKey) {
4298
+ const exact = markByOwner(ownerKey, index);
4299
+ if (exact) mark = exact;
4300
+ else if (quotesOwnerOf(mark) !== ownerKey) mark = null;
4301
+ }
4302
+ const text = (quote?.text ?? mark?.textContent ?? "").trim();
4303
+ if (text === "") return;
4304
+ if (activeChip === chip && preview) {
4305
+ pinned = pinned || nextPinned;
4306
+ return;
4307
+ }
4308
+ closeChipPreview(false);
4309
+ flushLeavingPreview();
4310
+ activeChip = chip;
4311
+ activeMark = mark;
4312
+ activeOwnerKey = ownerKey;
4313
+ activeMessageId = quote?.messageId ?? null;
4314
+ activeText = text;
4315
+ activeIndex = index;
4316
+ pinned = nextPinned;
4317
+ const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
4318
+ preview = box;
4319
+ box.style.visibility = "hidden";
4320
+ document.body.append(box);
4321
+ const panel = {
4322
+ width: box.offsetWidth || 320,
4323
+ height: box.offsetHeight || 180
4324
+ };
4325
+ const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
4326
+ box.style.top = `${top}px`;
4327
+ box.style.left = `${left}px`;
4328
+ box.style.visibility = "";
4329
+ const animated = box.querySelector(`.${styles.chipPreviewBody}`);
4330
+ if (animated) animated.style.animation = "";
4331
+ onDocPointerDown = (event) => {
4332
+ const target = event.target;
4333
+ if (!(target instanceof Node)) return;
4334
+ if (box.contains(target) || activeChip?.contains(target)) return;
4335
+ closeChipPreview(false);
4336
+ };
4337
+ document.addEventListener("pointerdown", onDocPointerDown, true);
4338
+ onScroll = scheduleReposition;
4339
+ document.addEventListener("scroll", onScroll, true);
4340
+ onResize = scheduleReposition;
4341
+ window.addEventListener("resize", onResize);
4342
+ observer = new MutationObserver(() => {
4343
+ if (activeChip && !activeChip.isConnected) closeChipPreview(false);
4344
+ });
4345
+ observer.observe(document.body, {
4346
+ childList: true,
4347
+ subtree: true
4348
+ });
4349
+ }
4350
+ /** Close the preview. `restoreFocus` is for a deliberate Escape. */
4351
+ function closeChipPreview(restoreFocus) {
4352
+ const box = preview;
4353
+ const chip = activeChip;
4354
+ cancelTimers();
4355
+ if (frame !== 0) {
4356
+ window.cancelAnimationFrame(frame);
4357
+ frame = 0;
4358
+ }
4359
+ if (observer) {
4360
+ observer.disconnect();
4361
+ observer = null;
4362
+ }
4363
+ if (onDocPointerDown) {
4364
+ document.removeEventListener("pointerdown", onDocPointerDown, true);
4365
+ onDocPointerDown = null;
4366
+ }
4367
+ if (onScroll) {
4368
+ document.removeEventListener("scroll", onScroll, true);
4369
+ onScroll = null;
4370
+ }
4371
+ if (onResize) {
4372
+ window.removeEventListener("resize", onResize);
4373
+ onResize = null;
4374
+ }
4375
+ preview = null;
4376
+ jumpButton = null;
4377
+ activeChip = null;
4378
+ activeMark = null;
4379
+ activeOwnerKey = null;
4380
+ activeMessageId = null;
4381
+ activeText = "";
4382
+ activeIndex = 0;
4383
+ pinned = false;
4384
+ if (restoreFocus && chip?.isConnected) {
4385
+ suppressFocusOpen = true;
4386
+ chip.focus({ preventScroll: true });
4387
+ }
4388
+ if (box) animatePreviewOut(box);
4389
+ suppressFocusOpen = false;
4390
+ }
4391
+ /** Bind the preview to a freshly decorated chip. Safe to call twice. */
4392
+ function bindChipPreview(chip, index) {
4393
+ if (chip.dataset.dsqChipPreviewBound === "true") return;
4394
+ chip.dataset.dsqChipPreviewBound = "true";
4395
+ chip.addEventListener("pointerenter", (event) => {
4396
+ if (event.pointerType === "touch") return;
4397
+ if (activeChip === chip && preview) {
4398
+ cancelClose();
4399
+ return;
4400
+ }
4401
+ scheduleShow(chip, index);
4402
+ });
4403
+ chip.addEventListener("pointerleave", (event) => {
4404
+ if (event.pointerType === "touch") return;
4405
+ scheduleClose();
4406
+ });
4407
+ chip.addEventListener("focus", () => {
4408
+ if (suppressFocusOpen) return;
4409
+ showChipPreview(chip, index, true);
4410
+ });
4411
+ chip.addEventListener("blur", (event) => {
4412
+ const next = event.relatedTarget;
4413
+ if (next instanceof Node && (preview?.contains(next) || chip.contains(next))) return;
4414
+ closeChipPreview(false);
4415
+ });
4416
+ chip.addEventListener("click", () => showChipPreview(chip, index, true));
4417
+ chip.addEventListener("keydown", (event) => {
4418
+ if (event.key === "Escape") {
4419
+ if (activeChip !== chip) return;
4420
+ event.preventDefault();
4421
+ closeChipPreview(true);
4422
+ return;
4423
+ }
4424
+ if (event.key === "ArrowDown") {
4425
+ event.preventDefault();
4426
+ showChipPreview(chip, index, true);
4427
+ jumpButton?.focus();
4428
+ }
4429
+ });
4430
+ }
4431
+ //#endregion
3188
4432
  //#region src/client/dom/decorateAnnotationDirectives.ts
3189
4433
  /**
3190
4434
  * Turn model-written `:dsh-annotation{index="N"}` into clickable chips.
@@ -3197,7 +4441,7 @@ function decorateTextNode(node) {
3197
4441
  if (!value || !value.includes(":dsh-annotation{")) return;
3198
4442
  const parent = node.parentElement;
3199
4443
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
3200
- if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
4444
+ if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
3201
4445
  if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
3202
4446
  const frag = document.createDocumentFragment();
3203
4447
  let last = 0;
@@ -3214,10 +4458,7 @@ function decorateTextNode(node) {
3214
4458
  chip.setAttribute("data-dsq-deco", "true");
3215
4459
  chip.dataset[DECO_KEY] = String(index);
3216
4460
  chip.textContent = t("chip.annotation", { index });
3217
- chip.addEventListener("click", () => {
3218
- const mark = findAnnotationMarkBefore(index, chip);
3219
- if (mark) flashAnnotationMark(mark);
3220
- });
4461
+ bindChipPreview(chip, index);
3221
4462
  frag.append(chip);
3222
4463
  last = m.index + m[0].length;
3223
4464
  }
@@ -3247,6 +4488,7 @@ function decorateAnnotationDirectives(root) {
3247
4488
  * literal, so `decorateTextNode()` returns before it can touch the DOM.
3248
4489
  */
3249
4490
  function refreshDirectiveChips(root) {
4491
+ closeChipPreview(false);
3250
4492
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
3251
4493
  if (!el) return;
3252
4494
  for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
@@ -3279,66 +4521,142 @@ function watchAnnotationDirectives(root) {
3279
4521
  //#endregion
3280
4522
  //#region src/client/dom/stripSubmissionEcho.ts
3281
4523
  /**
3282
- * Strip the annotation protocol from the host's local submission echo.
4524
+ * Strip the annotation protocol from host surfaces that paint the raw draft.
3283
4525
  *
3284
4526
  * On submit the host paints an optimistic bubble (`[data-submission-echo]`)
3285
4527
  * 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.
4528
+ * send gesture just folded in. A message submitted while a turn is running is
4529
+ * also previewed in the queue dock (`[data-queue-dock]`) from the same wire
4530
+ * content. Both are rendered by the chat flow itself, not by a
4531
+ * `conversation.chat.node` registration, so `UserMessageDisplay` never sees
4532
+ * them; without this, the raw JSON sits on screen until (or unless) a durable
4533
+ * message replaces it.
3290
4534
  *
3291
4535
  * 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.
4536
+ * only: the echo's protocol run is rewritten in place, and the queue preview's
4537
+ * word-split text container is rebuilt to the user's own text. The echo row
4538
+ * itself is never rebuilt (it may carry session chips); a truncated block is
4539
+ * left untouched, so nothing but our own protocol is edited.
3295
4540
  */
3296
4541
  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("*")) {
4542
+ const QUEUE_DOCK_SELECTOR = "[data-queue-dock]";
4543
+ const SURFACE_SELECTOR = `${ECHO_SELECTOR}, ${QUEUE_DOCK_SELECTOR}`;
4544
+ function carriesProtocol(text) {
4545
+ return text.includes("<response-annotations>") && text.includes("</response-annotations>");
4546
+ }
4547
+ /** Deepest runs that carry both markers. A queue dock may carry one per row. */
4548
+ function protocolRuns(row) {
4549
+ if (row.children.length === 0) return carriesProtocol(row.textContent ?? "") ? [row] : [];
4550
+ const runs = [];
4551
+ for (const el of Array.from(row.querySelectorAll("*"))) {
3301
4552
  if (el.children.length > 0) continue;
3302
- const text = el.textContent ?? "";
3303
- if (text.includes("<response-annotations>") && text.includes("</response-annotations>")) return el;
4553
+ if (carriesProtocol(el.textContent ?? "")) runs.push(el);
3304
4554
  }
3305
- return null;
4555
+ return runs;
4556
+ }
4557
+ /**
4558
+ * Rewrite one surface's protocol runs in place.
4559
+ *
4560
+ * Idempotent: once stripped a run no longer carries both markers, so a later
4561
+ * sweep (after the host re-renders the surface) is a no-op rather than a
4562
+ * reason to remember the node forever.
4563
+ */
4564
+ function stripEchoRow(row) {
4565
+ let stripped = false;
4566
+ for (const run of protocolRuns(row)) {
4567
+ const original = run.textContent ?? "";
4568
+ const scan = scanAnnotatedMessage(original);
4569
+ if (scan.rest === original) continue;
4570
+ run.textContent = scan.rest;
4571
+ stripped = true;
4572
+ }
4573
+ return stripped;
4574
+ }
4575
+ /**
4576
+ * Innermost text containers whose text carries a complete block.
4577
+ *
4578
+ * Queue previews go through the host's `conversation.user.text` factory, which
4579
+ * splits the draft into per-word spans; the open and close markers therefore
4580
+ * never share a leaf. The innermost ancestor holding both is the preview's
4581
+ * text container — rewriting that one leaves the row's icon and actions alone.
4582
+ */
4583
+ function textContainers(surface) {
4584
+ const all = Array.from(surface.querySelectorAll("*")).filter((el) => el.children.length > 0 && carriesProtocol(el.textContent ?? ""));
4585
+ return all.filter((el) => !all.some((other) => other !== el && el.contains?.(other) === true));
3306
4586
  }
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, "");
4587
+ /**
4588
+ * Rewrite each queued row's text container in place. Unlike the echo, the
4589
+ * preview is a word-split run with no chips or attachments inside it, so
4590
+ * rebuilding that container is safe; the row itself is never touched.
4591
+ */
4592
+ function stripQueueDock(dock) {
4593
+ let stripped = false;
4594
+ for (const container of textContainers(dock)) {
4595
+ const original = container.textContent ?? "";
4596
+ const scan = scanAnnotatedMessage(original);
4597
+ if (scan.rest === original) continue;
4598
+ container.textContent = scan.rest;
4599
+ stripped = true;
4600
+ }
4601
+ return stripped;
3315
4602
  }
3316
4603
  /**
3317
- * Watch for local submission echoes and rewrite the protocol block out of
3318
- * them. Runs once immediately, then on every DOM insertion.
4604
+ * Watch for host surfaces that paint the composed draft and rewrite the
4605
+ * protocol block out of them. Runs once immediately, then only on mutations
4606
+ * that can affect one of those surfaces.
3319
4607
  */
3320
4608
  function watchSubmissionEcho(root) {
3321
4609
  const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
3322
4610
  if (!scope) return () => {};
4611
+ const stripSurface = (surface) => {
4612
+ const node = surface;
4613
+ if (surface.matches(QUEUE_DOCK_SELECTOR)) stripQueueDock(node);
4614
+ else stripEchoRow(node);
4615
+ };
3323
4616
  const sweep = () => {
3324
- for (const row of scope.querySelectorAll(ECHO_SELECTOR)) strip(row);
4617
+ for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
3325
4618
  };
3326
4619
  sweep();
3327
4620
  if (typeof MutationObserver === "undefined") return () => {};
4621
+ const pending = /* @__PURE__ */ new Set();
3328
4622
  let frame = 0;
3329
- const observer = new MutationObserver(() => {
4623
+ const collect = (records) => {
4624
+ for (const record of records) {
4625
+ if (record.type === "characterData") {
4626
+ const row = record.target.parentElement?.closest(SURFACE_SELECTOR);
4627
+ if (row) pending.add(row);
4628
+ continue;
4629
+ }
4630
+ for (const node of Array.from(record.addedNodes)) {
4631
+ const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
4632
+ if (el === null) continue;
4633
+ const row = el.closest(SURFACE_SELECTOR);
4634
+ if (row) {
4635
+ pending.add(row);
4636
+ continue;
4637
+ }
4638
+ for (const inner of Array.from(el.querySelectorAll(SURFACE_SELECTOR))) pending.add(inner);
4639
+ }
4640
+ }
4641
+ };
4642
+ const observer = new MutationObserver((records) => {
4643
+ collect(records);
4644
+ if (pending.size === 0) return;
3330
4645
  if (frame !== 0) return;
3331
4646
  frame = window.requestAnimationFrame(() => {
3332
4647
  frame = 0;
3333
- sweep();
4648
+ for (const surface of pending) stripSurface(surface);
4649
+ pending.clear();
3334
4650
  });
3335
4651
  });
3336
4652
  observer.observe(scope, {
3337
4653
  childList: true,
3338
- subtree: true
4654
+ subtree: true,
4655
+ characterData: true
3339
4656
  });
3340
4657
  return () => {
3341
4658
  observer.disconnect();
4659
+ pending.clear();
3342
4660
  if (frame !== 0) window.cancelAnimationFrame(frame);
3343
4661
  };
3344
4662
  }
@@ -3370,6 +4688,12 @@ function TranscriptQuoteCard({ node }) {
3370
4688
  endOffset: quote.endOffset
3371
4689
  })));
3372
4690
  }, [quotes]);
4691
+ (0, react.useEffect)(() => {
4692
+ if (quotes.length === 0) return;
4693
+ const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
4694
+ if (!ownerKey) return;
4695
+ return registerMessageQuotes(ownerKey, quotes);
4696
+ }, [quotes]);
3373
4697
  const markIdOf = (0, react.useCallback)((index) => {
3374
4698
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
3375
4699
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
@@ -3438,9 +4762,9 @@ function imageAttachments(raw) {
3438
4762
  * block is removed from the text blocks, everything else (attachments, file
3439
4763
  * cards, reference labels, time, copy, and any plugin that portals into the
3440
4764
  * 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.
4765
+ * The annotation summary is the only extra DOM, prepended above the host row.
3442
4766
  *
3443
- * Cards must live inside the `user` node view rather than on a
4767
+ * The summary must live inside the `user` node view rather than on a
3444
4768
  * `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
3445
4769
  * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3446
4770
  * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
@@ -3448,10 +4772,10 @@ function imageAttachments(raw) {
3448
4772
  */
3449
4773
  function UserMessageDisplay(props) {
3450
4774
  ensureToolbarStyles();
3451
- const { node, hostUserOf, ...rest } = props;
4775
+ const { node, hostNodeOf, ...rest } = props;
3452
4776
  const raw = contentBlocksToText(node.data?.content);
3453
4777
  const scanned = scanAnnotatedMessage(raw);
3454
- const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
4778
+ const text = displayTextWithoutQuote(scanned.rest).trim();
3455
4779
  const quotes = scanned.annotations;
3456
4780
  const rewritten = quotes.length > 0 || text !== raw.trim();
3457
4781
  const displayNode = (0, react.useMemo)(() => rewritten ? {
@@ -3466,7 +4790,7 @@ function UserMessageDisplay(props) {
3466
4790
  text
3467
4791
  ]);
3468
4792
  const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
3469
- const Host = hostUserOf?.();
4793
+ const Host = hostNodeOf?.();
3470
4794
  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
4795
  ...rest,
3472
4796
  node: displayNode
@@ -3496,6 +4820,27 @@ function UserMessageDisplay(props) {
3496
4820
  })] });
3497
4821
  }
3498
4822
  //#endregion
4823
+ //#region src/client/transcript/host-user.ts
4824
+ const HOST_NODE_NAME = "UserMessageNodeView";
4825
+ /** Component name across memo/forwardRef/plain function shapes. */
4826
+ function componentName(component) {
4827
+ const candidate = component;
4828
+ return candidate?.type?.name ?? candidate?.render?.name ?? candidate?.name ?? candidate?.displayName;
4829
+ }
4830
+ /**
4831
+ * The host view registered for `key`, or undefined when it cannot be
4832
+ * identified safely.
4833
+ *
4834
+ * A renamed host leaves one non-self entry for that key, which is still
4835
+ * almost certainly the host; more than one is ambiguous, so callers should
4836
+ * show the hostless fallback instead of delegating to an arbitrary plugin's
4837
+ * wrapper.
4838
+ */
4839
+ function pickHostNode(entries, self, key) {
4840
+ const candidates = entries.filter((entry) => entry.options?.key === key && entry.component !== void 0 && entry.component !== self);
4841
+ return (candidates.find((entry) => componentName(entry.component) === HOST_NODE_NAME) ?? (candidates.length === 1 ? candidates[0] : void 0))?.component;
4842
+ }
4843
+ //#endregion
3499
4844
  //#region src/client/state/annotation-persistence.ts
3500
4845
  /** localStorage prefix; a session id is appended as `.<sessionId>`. */
3501
4846
  const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
@@ -3652,7 +4997,7 @@ function createAnnotationPersistence() {
3652
4997
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
3653
4998
  * are always observed. Annotation summary stays on the session composer.
3654
4999
  *
3655
- * History cards render **inside** the `user` Chat node view, not on a
5000
+ * History summary renders **inside** the `user` Chat node view, not on a
3656
5001
  * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
3657
5002
  * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3658
5003
  * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
@@ -3696,7 +5041,20 @@ function apply(ctx) {
3696
5041
  const port = createAnnotationPersistence();
3697
5042
  setAnnotationPersistence(port);
3698
5043
  port.prune();
3699
- return () => setAnnotationPersistence(null);
5044
+ const onStorage = (event) => {
5045
+ if (event.storageArea !== null && event.storageArea !== window.localStorage) return;
5046
+ if (event.key === null) {
5047
+ adoptStorageAnnotations(null);
5048
+ return;
5049
+ }
5050
+ const sessionId = sessionIdOfKey(event.key);
5051
+ if (sessionId !== null) adoptStorageAnnotations(sessionId);
5052
+ };
5053
+ window.addEventListener("storage", onStorage);
5054
+ return () => {
5055
+ window.removeEventListener("storage", onStorage);
5056
+ setAnnotationPersistence(null);
5057
+ };
3700
5058
  }, "dsh-select-quote: annotation persistence");
3701
5059
  ctx.slots.inject("shell.overlay", () => {
3702
5060
  ctx.slots.register({
@@ -3719,28 +5077,30 @@ function apply(ctx) {
3719
5077
  order: 1
3720
5078
  }, SessionBridge);
3721
5079
  });
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;
5080
+ const hostNodeOf = (key, self) => {
5081
+ return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
3725
5082
  };
3726
- const UserNode = (props) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
3727
- ...props,
3728
- hostUserOf
3729
- });
3730
- ctx.slots.inject("conversation.chat.node", () => {
5083
+ function UserNode(props) {
5084
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
5085
+ ...props,
5086
+ hostNodeOf: () => hostNodeOf("user", UserNode)
5087
+ });
5088
+ }
5089
+ function SteeringNode(props) {
5090
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
5091
+ ...props,
5092
+ hostNodeOf: () => hostNodeOf("steering", SteeringNode)
5093
+ });
5094
+ }
5095
+ for (const [key, View] of [["user", UserNode], ["steering", SteeringNode]]) ctx.slots.inject("conversation.chat.node", () => {
3731
5096
  ctx.slots.register({
3732
5097
  name: "conversation.chat.node",
3733
- key: "user",
5098
+ key,
3734
5099
  priority: -10,
3735
5100
  locale: "chat"
3736
- }, UserNode);
5101
+ }, View);
3737
5102
  });
3738
5103
  }
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
5104
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3745
5105
  function SessionBridge({ sessionId }) {
3746
5106
  bindAnnotationSession(sessionId);