dsh-select-quote 0.3.7 → 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,12 +256,14 @@ ${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
- 摘要行与批注卡片在内容普通流里,不参与这套层带;悬停面板仍是 body 级 fixed
227
- (原因见 AnnotationCardView 里 contain:layout 的说明)。 */
265
+ 摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
266
+ body 级 fixed(原因见 AnnotationCardView 里 contain:layout 的说明)。 */
228
267
 
229
268
  .dsq_toolbar {
230
269
  position: absolute;
@@ -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;
@@ -334,78 +383,8 @@ ${atomRules}
334
383
  pointer-events: auto;
335
384
  }
336
385
 
337
- /* One annotation card, shared by the composer and the transcript: the base
338
- carries the shape, and \`.dsq_tCard\` / \`.dsq_tCardIcon\` override only the
339
- numbers where the transcript really differs.
340
- Geometry mirrors the product's own file card (\`.nyYjTG_file\` in
341
- ui-deliverables): hairline border, neutral fill, 18px radius, fixed 72px row. */
342
- .dsq_card {
343
- --dsq-card-fill: var(--dsq-neutral-50);
344
- --dsq-card-hover: var(--dsq-neutral-100);
345
- flex: 0 0 auto;
346
- gap: 10px;
347
- width: fit-content;
348
- max-width: calc(25% - 5px);
349
- min-width: 0;
350
- height: 64px;
351
- margin: 0;
352
- padding: 10px;
353
- border: 0.5px solid var(--dsq-line);
354
- border-radius: 18px;
355
- background: var(--dsq-card-fill);
356
- color: var(--dsq-label);
357
- transition: background-color 0.12s;
358
- position: relative;
359
- overflow: hidden;
360
- user-select: none;
361
- pointer-events: auto;
362
- }
363
-
364
- .dsq_card:hover {
365
- background: var(--dsq-card-hover);
366
- }
367
-
368
- /* Scaled down from the reference 48px: the composer card is width-capped at a
369
- quarter of the input, so 48px would leave no room for the quote text. */
370
- .dsq_cardIcon {
371
- z-index: 2;
372
- width: 36px;
373
- height: 36px;
374
- border: 0.5px solid var(--dsq-line);
375
- border-radius: 12px;
376
- background: var(--dsq-card-fill);
377
- color: var(--dsq-link);
378
- font-size: 12px;
379
- font-weight: 600;
380
- letter-spacing: -0.02em;
381
- }
382
-
383
- .dsq_cardBody {
384
- z-index: 2;
385
- justify-content: center;
386
- gap: 2px;
387
- }
388
-
389
- /* Keeps the ellipsised title clear of the corner remove button, which only the
390
- composer stack renders. */
391
- .dsq_cardStack .dsq_cardBody {
392
- padding-right: 14px;
393
- }
394
-
395
- .dsq_cardTitle {
396
- font-size: 14px;
397
- font-weight: 500;
398
- line-height: 22px;
399
- color: var(--dsq-label);
400
- }
401
-
402
- .dsq_cardSubtitle {
403
- font-size: 12px;
404
- line-height: 18px;
405
- color: var(--dsq-label-3);
406
- }
407
-
408
- /* Corner remove button: hidden until the card is hovered or focused. */
386
+ /* Corner remove-all button on the summary block: hidden until the block is
387
+ hovered or focused. */
409
388
  .dsq_cardClose {
410
389
  position: absolute;
411
390
  top: 4px;
@@ -422,8 +401,6 @@ ${atomRules}
422
401
  transition: opacity 0.12s, background-color 0.12s;
423
402
  }
424
403
 
425
- .dsq_card:hover .dsq_cardClose,
426
- .dsq_card:focus-within .dsq_cardClose,
427
404
  .dsq_summary:hover .dsq_cardClose,
428
405
  .dsq_summary:focus-within .dsq_cardClose {
429
406
  opacity: 1;
@@ -435,33 +412,6 @@ ${atomRules}
435
412
  color: var(--dsq-label);
436
413
  }
437
414
 
438
- /* Secondary corner action (edit comment). */
439
- .dsq_cardAction {
440
- position: absolute;
441
- top: 4px;
442
- right: 28px;
443
- z-index: 3;
444
- width: 20px;
445
- height: 20px;
446
- border-radius: 6px;
447
- color: var(--dsq-label-3);
448
- font-size: 12px;
449
- line-height: 1;
450
- opacity: 0;
451
- pointer-events: none;
452
- transition: opacity 0.12s, background-color 0.12s;
453
- }
454
-
455
- .dsq_card:hover .dsq_cardAction,
456
- .dsq_card:focus-within .dsq_cardAction {
457
- opacity: 1;
458
- pointer-events: auto;
459
- }
460
-
461
- .dsq_cardAction:hover {
462
- background: var(--dsq-fill-hover);
463
- }
464
-
465
415
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
466
416
  .dsq_summary {
467
417
  position: relative;
@@ -472,7 +422,7 @@ ${atomRules}
472
422
  padding: 8px 10px;
473
423
  border: 0.5px solid var(--dsq-line);
474
424
  border-radius: 18px;
475
- background: var(--dsq-neutral-50);
425
+ background: var(--dsq-summary-bg);
476
426
  pointer-events: auto;
477
427
  user-select: none;
478
428
  }
@@ -491,7 +441,9 @@ ${atomRules}
491
441
  position: relative;
492
442
  }
493
443
 
494
- .dsq_summaryTitle {
444
+ /* Card titles (composer summary / touch sheet) share one type scale. */
445
+ .dsq_summaryTitle,
446
+ .dsq_touchSheetTitle {
495
447
  font-size: 14px;
496
448
  font-weight: 500;
497
449
  line-height: 20px;
@@ -504,18 +456,37 @@ ${atomRules}
504
456
  color: var(--dsq-label-3);
505
457
  }
506
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
+
507
480
  .dsq_summaryHover {
508
481
  /* \`display: none\` here is the closed state, so this rule must not take the
509
482
  flex atoms — the open class below turns display back on. */
510
483
  display: none;
511
484
  position: fixed;
512
485
  z-index: 14;
513
- width: min(320px, calc(100vw - 16px));
514
486
  flex-direction: column;
515
- padding: 12px;
516
- /* Facing-side pad is set in JS from placement side; this is the base. */
517
- padding-top: 12px;
518
- 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;
519
490
  background: transparent;
520
491
  pointer-events: auto;
521
492
  opacity: 0;
@@ -531,14 +502,22 @@ ${atomRules}
531
502
  display: flex;
532
503
  }
533
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
+
534
514
  .dsq_summaryHoverInner {
535
515
  position: relative;
536
516
  gap: 4px;
537
517
  padding: 4px;
538
518
  border-radius: 12px;
539
519
  /* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
540
- 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里。 */
541
- box-shadow: var(--dsq-shadow);
520
+ 描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
542
521
  }
543
522
 
544
523
  /* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
@@ -556,7 +535,9 @@ ${atomRules}
556
535
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
557
536
  .dsq_toolbar::before,
558
537
  .dsq_commentPop::before,
559
- .dsq_summaryHoverInner::before {
538
+ .dsq_summaryHoverInner::before,
539
+ .dsq_touchBar::before,
540
+ .dsq_chipPreview::before {
560
541
  background: var(--dsq-bg-raised);
561
542
  backdrop-filter: none;
562
543
  -webkit-backdrop-filter: none;
@@ -576,6 +557,9 @@ ${atomRules}
576
557
 
577
558
  .dsq_summaryItemHead {
578
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;
579
563
  }
580
564
 
581
565
  .dsq_summaryItemBadge {
@@ -615,6 +599,9 @@ ${atomRules}
615
599
 
616
600
  .dsq_summaryItemMain {
617
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;
618
605
  }
619
606
 
620
607
  .dsq_summaryItemButton {
@@ -623,7 +610,9 @@ ${atomRules}
623
610
  text-align: left;
624
611
  font: inherit;
625
612
  border-radius: 8px;
626
- 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;
627
616
  }
628
617
 
629
618
  .dsq_annAnchor {
@@ -634,18 +623,36 @@ ${atomRules}
634
623
  pointer-events: none;
635
624
  }
636
625
 
637
- .dsq_summaryItemLabel {
626
+ /* Field labels shared by the hover list and the chip preview. */
627
+ .dsq_summaryItemLabel,
628
+ .dsq_chipPreviewLabel {
638
629
  font-size: 11px;
639
630
  line-height: 16px;
640
631
  color: var(--dsq-label-3);
641
632
  }
642
633
 
643
- .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 {
644
648
  font-size: 13px;
645
- line-height: 18px;
646
649
  color: var(--dsq-label);
647
650
  white-space: pre-wrap;
648
651
  overflow-wrap: anywhere;
652
+ }
653
+
654
+ .dsq_summaryItemText {
655
+ line-height: 18px;
649
656
  display: -webkit-box;
650
657
  -webkit-line-clamp: 3;
651
658
  -webkit-box-orient: vertical;
@@ -656,24 +663,22 @@ ${atomRules}
656
663
  color: var(--dsq-label-3);
657
664
  }
658
665
 
659
- /* Inline edit form rendered inside the hover panel item (no extra layer). */
660
- .dsq_annEditText {
661
- font-size: 13px;
662
- line-height: 20px;
663
- color: var(--dsq-label);
664
- white-space: pre-wrap;
665
- 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;
666
671
  max-height: 132px;
667
672
  overflow-y: auto;
668
- margin: 2px 0 6px;
673
+ overscroll-behavior: contain;
669
674
  }
670
675
 
671
- .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 {
672
680
  width: 100%;
673
- min-height: 72px;
674
- resize: vertical;
675
681
  border: none;
676
- border-radius: 10px;
677
682
  padding: 8px 10px;
678
683
  font: inherit;
679
684
  font-size: 13px;
@@ -683,44 +688,47 @@ ${atomRules}
683
688
  outline: none;
684
689
  }
685
690
 
686
- .dsq_annEditInput::placeholder {
691
+ .dsq_annEditInput::placeholder,
692
+ .dsq_commentInput::placeholder {
687
693
  color: var(--dsq-label-3);
688
694
  }
689
695
 
696
+ .dsq_annEditInput {
697
+ min-height: 72px;
698
+ resize: vertical;
699
+ border-radius: 10px;
700
+ }
701
+
702
+ .dsq_commentActions,
690
703
  .dsq_annEditActions {
691
704
  justify-content: flex-end;
705
+ }
706
+
707
+ .dsq_annEditActions {
692
708
  gap: 8px;
693
709
  margin-top: 4px;
694
710
  }
695
711
 
696
- .dsq_annEditCancel {
697
- 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 {
698
716
  font: inherit;
699
717
  font-size: 13px;
700
718
  padding: 4px 12px;
701
719
  border-radius: 10px;
702
720
  }
703
721
 
722
+ .dsq_annEditCancel {
723
+ color: var(--dsq-label-2);
724
+ }
725
+
704
726
  .dsq_annEditCancel:hover {
705
727
  background: var(--dsq-fill-hover);
706
728
  }
707
729
 
708
730
  .dsq_annEditSave {
709
- appearance: none;
710
- border: none;
711
- background: var(--dsq-accent);
712
- color: #fff;
713
- font: inherit;
714
- font-size: 13px;
715
731
  font-weight: 500;
716
- padding: 4px 12px;
717
- border-radius: 10px;
718
- cursor: pointer;
719
- transition: filter 0.12s;
720
- }
721
-
722
- .dsq_annEditSave:hover {
723
- background: var(--dsq-accent-400);
724
732
  }
725
733
 
726
734
  .dsq_annEditSave:disabled {
@@ -739,129 +747,245 @@ ${atomRules}
739
747
  gap: 8px;
740
748
  padding: 8px;
741
749
  border-radius: 16px;
742
- /* 底色与模糊都在材质图层里 */
743
- box-shadow: var(--dsq-shadow);
750
+ /* 底色与模糊都在材质图层里,elevation 在共享规则里 */
744
751
  /* The layer host is pointer-events:none; interactive surfaces opt back in. */
745
752
  pointer-events: auto;
746
753
  }
747
754
 
748
755
  .dsq_commentInput {
749
- width: 100%;
750
756
  min-height: 64px;
751
757
  resize: none;
752
- border: none;
753
758
  border-radius: 8px;
754
- padding: 8px 10px;
755
- font: inherit;
756
- font-size: 13px;
757
- line-height: 20px;
758
- color: var(--dsq-label);
759
- background: var(--dsq-fill-hover);
760
- outline: none;
761
- }
762
-
763
- .dsq_commentInput::placeholder {
764
- color: var(--dsq-label-3);
765
759
  }
766
760
 
767
761
  .dsq_commentActions {
768
- display: flex;
769
- justify-content: flex-end;
770
762
  gap: 6px;
771
763
  }
772
764
 
773
- /* Transcript cards — in-flow rows under the user bubble, side by side. */
774
- .dsq_tCardStack {
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 {
775
782
  display: flex;
776
- flex-direction: row;
777
- flex-wrap: wrap;
778
- justify-content: flex-end;
779
- align-items: stretch;
783
+ flex-direction: column;
780
784
  gap: 6px;
781
- margin: 4px 0 8px;
782
- /* Cards lay out horizontally and wrap onto the next row when they run out of
783
- width. Each card carries the selected text plus the user's comment, so they
784
- stay compact and stay on the user-bubble side (right). */
785
- max-width: 100%;
786
- margin-left: auto;
787
- margin-right: 0;
785
+ animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
788
786
  }
789
787
 
790
- /* Transcript-only differences from the shared card. Unlike the composer
791
- summary (one collapsed row per message) each history card shows its own
792
- comment, so the fixed height and single-line clamps are lifted on \`.dsq_tCardButton\`. */
793
- .dsq_tCard {
794
- max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
795
- margin-left: auto;
788
+ .dsq_chipPreviewLeaving {
789
+ pointer-events: none;
796
790
  }
797
791
 
798
- .dsq_tCardIcon {
799
- width: 40px;
800
- height: 40px;
801
- font-size: 14px;
792
+ .dsq_chipPreviewLeaving .dsq_chipPreviewBody {
793
+ animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
802
794
  }
803
795
 
804
- .dsq_tCardButton {
805
- /* Compact cards: several fit in one row before wrapping. */
806
- display: block;
807
- flex: 0 1 auto;
808
- width: 230px;
809
- max-width: 100%;
810
- padding: 0;
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 {
804
+ font-size: 13px;
805
+ font-weight: 600;
806
+ line-height: 18px;
807
+ color: var(--dsq-label);
808
+ }
809
+
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 {
827
+ color: var(--dsq-label-3);
828
+ }
829
+
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;
811
837
  font: inherit;
812
- text-align: left;
813
- border-radius: 14px;
838
+ font-size: 12px;
839
+ font-weight: 500;
840
+ line-height: 1;
841
+ white-space: nowrap;
814
842
  }
815
843
 
816
- .dsq_tCardButton:focus-visible {
817
- outline: 2px solid var(--dsq-accent);
818
- outline-offset: 2px;
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;
819
858
  }
820
859
 
821
- .dsq_tCardButton .dsq_card {
822
- height: auto;
823
- min-height: 0;
824
- align-items: flex-start;
825
- width: 100%;
826
- gap: 8px;
827
- padding: 8px;
828
- border-radius: 14px;
860
+ .dsq_touchBar {
861
+ z-index: 60;
862
+ gap: 4px;
863
+ padding: 6px;
864
+ border-radius: 16px;
829
865
  }
830
866
 
831
- .dsq_tCardButton .dsq_cardIcon {
832
- width: 22px;
833
- height: 22px;
834
- border-radius: 8px;
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;
835
878
  font-size: 11px;
879
+ line-height: 14px;
836
880
  }
837
881
 
838
- .dsq_tCardButton .dsq_cardTitle {
839
- display: -webkit-box;
840
- -webkit-line-clamp: 2;
841
- -webkit-box-orient: vertical;
882
+ .dsq_touchAction span {
883
+ max-width: 100%;
842
884
  overflow: hidden;
843
- white-space: normal;
844
- overflow-wrap: anywhere;
845
- font-size: 13px;
846
- line-height: 18px;
885
+ text-overflow: ellipsis;
886
+ white-space: nowrap;
847
887
  }
848
888
 
849
- .dsq_tCardButton .dsq_cardSubtitle {
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;
850
936
  display: -webkit-box;
851
- -webkit-line-clamp: 3;
937
+ -webkit-line-clamp: 2;
852
938
  -webkit-box-orient: vertical;
853
939
  overflow: hidden;
854
- white-space: pre-wrap;
855
- overflow-wrap: anywhere;
856
- margin-top: 2px;
857
- font-size: 12px;
858
- line-height: 16px;
859
940
  }
860
941
 
861
- /* A comment is the user's own words — give it more presence than the neutral
862
- "no comment" placeholder. */
863
- .dsq_cardSubtitle[data-dsq-has-comment='true'] {
864
- color: var(--dsq-label-2);
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);
970
+ }
971
+
972
+ /* Transcript summary — the same block as the composer's, right-aligned under
973
+ the user bubble. One block per message; the hover list carries every
974
+ annotation with its selected text and comment. */
975
+ .dsq_tSummary {
976
+ display: flex;
977
+ justify-content: flex-end;
978
+ margin: 4px 0 8px;
979
+ }
980
+
981
+ .dsq_tSummary .dsq_summary {
982
+ width: auto;
983
+ min-width: 160px;
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;
865
989
  }
866
990
 
867
991
  /* Replacement user bubble (quote block stripped from display). */
@@ -892,6 +1016,12 @@ ${atomRules}
892
1016
  max-width: 100%;
893
1017
  }
894
1018
 
1019
+ .dsq_userImages img {
1020
+ display: block;
1021
+ max-width: 100%;
1022
+ border-radius: 12px;
1023
+ }
1024
+
895
1025
  /* Model-written :dsh-annotation{index="N"} chips. */
896
1026
  .dsq_annDirective {
897
1027
  appearance: none;
@@ -952,13 +1082,74 @@ ${atomRules}
952
1082
  }
953
1083
 
954
1084
  .dsq_annMark:hover {
955
- background: var(--dsq-accent-tint-hover);
1085
+ background: var(--dsq-mark-hover);
956
1086
  }
957
1087
 
958
1088
  .dsq_annMarkFlash {
959
1089
  animation: dsq-ann-flash 1.4s ease-out;
960
1090
  }
961
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
+
962
1153
  /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
963
1154
  into product-owned DOM. */
964
1155
 
@@ -966,26 +1157,23 @@ ${atomRules}
966
1157
  0% {
967
1158
  background: var(--dsq-mark-color);
968
1159
  color: #fff;
1160
+ box-shadow: 0 0 0 3px var(--dsq-mark-tint);
969
1161
  }
970
1162
  100% {
971
1163
  background: var(--dsq-mark-tint);
972
1164
  color: inherit;
1165
+ box-shadow: 0 0 0 0 transparent;
973
1166
  }
974
1167
  }
975
1168
 
976
1169
  /* The product flips its palette on body[data-ds-dark-theme], and every
977
- --dsq-* alias follows it. Only values we own ourselves need restating — the
978
- transcript card carries .dsq_card too, so one selector covers both. */
979
- body[data-ds-dark-theme] .dsq_card {
980
- --dsq-card-fill: var(--dsq-neutral-850);
981
- --dsq-card-hover: var(--dsq-neutral-800);
982
- }
983
-
1170
+ --dsq-* alias follows it. Only values we own ourselves need restating. */
984
1171
  body[data-ds-dark-theme] .dsq_annMark {
985
1172
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
1173
+ --dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
986
1174
  }
987
1175
 
988
- /* 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
989
1177
  attachment rail and the editor flow below them instead of being covered. */
990
1178
  .dsq_cardPad {
991
1179
  padding-top: var(--dsq-quote-pad, 88px) !important;
@@ -1009,20 +1197,17 @@ const styles = {
1009
1197
  divider: "dsq_divider",
1010
1198
  status: "dsq_status",
1011
1199
  cardStack: "dsq_cardStack",
1012
- card: "dsq_card",
1013
- cardIcon: "dsq_cardIcon",
1014
- cardBody: "dsq_cardBody",
1015
- cardTitle: "dsq_cardTitle",
1016
- cardSubtitle: "dsq_cardSubtitle",
1017
1200
  cardClose: "dsq_cardClose",
1018
1201
  cardPad: "dsq_cardPad",
1019
- cardAction: "dsq_cardAction",
1020
1202
  summary: "dsq_summary",
1021
1203
  summaryIcon: "dsq_summaryIcon",
1022
1204
  summaryBody: "dsq_summaryBody",
1023
1205
  summaryTitle: "dsq_summaryTitle",
1024
1206
  summaryHint: "dsq_summaryHint",
1207
+ summaryHintPointer: "dsq_summaryHintPointer",
1208
+ summaryHintTouch: "dsq_summaryHintTouch",
1025
1209
  summaryHover: "dsq_summaryHover",
1210
+ summaryHoverLeaving: "dsq_summaryHoverLeaving",
1026
1211
  summaryItem: "dsq_summaryItem",
1027
1212
  summaryItemLabel: "dsq_summaryItemLabel",
1028
1213
  summaryItemText: "dsq_summaryItemText",
@@ -1030,7 +1215,7 @@ const styles = {
1030
1215
  summaryItemMain: "dsq_summaryItemMain",
1031
1216
  summaryItemHead: "dsq_summaryItemHead",
1032
1217
  summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
1033
- annEditText: "dsq_annEditText",
1218
+ summaryItemTextEdit: "dsq_summaryItemTextEdit",
1034
1219
  annEditInput: "dsq_annEditInput",
1035
1220
  annEditActions: "dsq_annEditActions",
1036
1221
  annEditCancel: "dsq_annEditCancel",
@@ -1045,13 +1230,33 @@ const styles = {
1045
1230
  annAnchor: "dsq_annAnchor",
1046
1231
  annMark: "dsq_annMark",
1047
1232
  annMarkFlash: "dsq_annMarkFlash",
1233
+ annMessageFlash: "dsq_annMessageFlash",
1048
1234
  commentPop: "dsq_commentPop",
1049
1235
  commentInput: "dsq_commentInput",
1050
1236
  commentActions: "dsq_commentActions",
1051
- tCardStack: "dsq_tCardStack",
1052
- tCardButton: "dsq_tCardButton",
1053
- tCard: "dsq_tCard",
1054
- tCardIcon: "dsq_tCardIcon",
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",
1259
+ tSummary: "dsq_tSummary",
1055
1260
  userRow: "dsq_userRow",
1056
1261
  userStack: "dsq_userStack",
1057
1262
  userImages: "dsq_userImages",
@@ -1064,7 +1269,7 @@ const styles = {
1064
1269
  * it as stable character offsets over the message's own text.
1065
1270
  *
1066
1271
  * Offsets are computed over "clean text": the flow item's text nodes with the
1067
- * plugin's own chrome (summary cards, directive chips, anchors) skipped. Marks
1272
+ * plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
1068
1273
  * are transparent — their text still counts — so an offset captured before a
1069
1274
  * mark exists stays valid after decoration.
1070
1275
  */
@@ -1272,16 +1477,15 @@ function decorateBySearch(item, job) {
1272
1477
  const full = cleanTextOf(item);
1273
1478
  const needle = job.text.trim();
1274
1479
  if (needle.length < 2) return false;
1275
- const patterns = [needle, needle.split(/\s+/).map(escapeRegExp).join("\\s*")];
1276
- for (const pattern of patterns) {
1277
- let at = full.indexOf(pattern);
1278
- while (at >= 0) {
1279
- const located = cleanTextRange(item, at, at + pattern.length);
1280
- if (located) {
1281
- const pieces = overlappingEntries(located.entries, located.from, located.to);
1282
- if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
1283
- }
1284
- 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;
1285
1489
  }
1286
1490
  }
1287
1491
  return false;
@@ -1293,6 +1497,19 @@ function decorateJob(item, job) {
1293
1497
  if (decorateAtOffsets(item, job)) return true;
1294
1498
  return decorateBySearch(item, job);
1295
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
+ }
1296
1513
  /** Apply every registered job whose message is currently rendered. */
1297
1514
  function applyAnnotationMarks(root) {
1298
1515
  if (jobs.size === 0) return;
@@ -1300,10 +1517,15 @@ function applyAnnotationMarks(root) {
1300
1517
  if (!scope) return;
1301
1518
  for (const job of [...jobs.values()]) {
1302
1519
  const items = flowItems(job.messageId, scope);
1520
+ let decorated = false;
1303
1521
  for (const item of items) {
1304
- if (item.querySelector(`[${MARK_ATTR}="${attrValue(job.markId)}"]`)) break;
1522
+ if (refreshMarkIndex(item, job)) {
1523
+ decorated = true;
1524
+ continue;
1525
+ }
1526
+ if (decorated) continue;
1305
1527
  if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
1306
- if (decorateJob(item, job)) break;
1528
+ if (decorateJob(item, job)) decorated = true;
1307
1529
  }
1308
1530
  }
1309
1531
  }
@@ -1359,6 +1581,7 @@ function watchAnnotationMarks(root) {
1359
1581
  applyAnnotationMarks(el);
1360
1582
  let frame = 0;
1361
1583
  const schedule = () => {
1584
+ if (jobs.size === 0) return;
1362
1585
  if (frame !== 0) return;
1363
1586
  frame = window.requestAnimationFrame(() => {
1364
1587
  frame = 0;
@@ -1577,61 +1800,106 @@ function composeAnnotatedMessage(draft, annotations) {
1577
1800
  const rest = stripDraftMarker(draft).replace(/^\s+/, "");
1578
1801
  if (annotations.length === 0) return rest;
1579
1802
  const payload = annotations.map(toWireItem);
1803
+ const json = JSON.stringify(payload).replace(/</g, "\\u003c");
1580
1804
  const block = [
1581
1805
  PROTOCOL_HEADER,
1582
1806
  "",
1583
1807
  ANNOTATIONS_BLOCK_OPEN,
1584
- JSON.stringify(payload),
1808
+ json,
1585
1809
  ANNOTATIONS_BLOCK_CLOSE
1586
1810
  ].join("\n");
1587
1811
  return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
1588
1812
  }
1589
1813
  /** Remove `# Response annotations:` instructional prose before the JSON block. */
1590
1814
  function stripProtocolHeader(prefix) {
1591
- 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+$/, "");
1592
1817
  return prefix.replace(/\s+$/, "");
1593
1818
  }
1819
+ function blockParts(text) {
1820
+ const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1821
+ if (open < 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;
1838
+ return {
1839
+ before: stripProtocolHeader(text.slice(0, open)),
1840
+ after: text.slice(firstClose + 23).replace(/^\s+/, "")
1841
+ };
1842
+ }
1843
+ function joinedRest(parts) {
1844
+ return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
1845
+ }
1846
+ /** Wire payload as an array; null when the block is not an array. */
1847
+ function parseWire(parts) {
1848
+ return Array.isArray(parts.parsed) ? parts.parsed : null;
1849
+ }
1850
+ /** Every source an item carries; falls back to the selected text itself. */
1851
+ function sourcesOf(record, body) {
1852
+ const single = record.source;
1853
+ if (single !== null && typeof single === "object" && typeof single.text === "string") return [single];
1854
+ if (Array.isArray(record.sources)) {
1855
+ const list = record.sources.filter((item) => item !== null && typeof item === "object" && typeof item.text === "string");
1856
+ if (list.length > 0) return list;
1857
+ }
1858
+ return [{ text: body }];
1859
+ }
1860
+ /** One usable wire item; null for entries without a selected text. */
1861
+ function wireBody(entry) {
1862
+ if (entry === null || typeof entry !== "object") return null;
1863
+ const record = entry;
1864
+ const body = typeof record.text === "string" ? record.text.trim() : "";
1865
+ if (!body) return null;
1866
+ return {
1867
+ body,
1868
+ comment: typeof record.annotation === "string" ? record.annotation : "",
1869
+ sources: sourcesOf(record, body)
1870
+ };
1871
+ }
1594
1872
  /**
1595
1873
  * Read plugin annotations out of a durable message. Tolerates the JSON block
1596
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.
1597
1878
  */
1598
1879
  function scanAnnotatedMessage(text) {
1599
- const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1600
- const close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE);
1601
- if (open < 0 || close < 0 || close < open) return {
1880
+ const parts = blockParts(text);
1881
+ if (parts === null) return {
1602
1882
  annotations: [],
1603
1883
  rest: text
1604
1884
  };
1605
- const jsonStart = open + 22;
1606
- const jsonText = text.slice(jsonStart, close).trim();
1607
- const rest = [stripProtocolHeader(text.slice(0, open)), text.slice(close + 23).replace(/^\s+/, "")].filter((part) => part.length > 0).join("\n\n").trim();
1608
- let parsed;
1609
- try {
1610
- parsed = JSON.parse(jsonText);
1611
- } catch {
1612
- return {
1613
- annotations: [],
1614
- rest: text.replace(/^\s+/, "")
1615
- };
1616
- }
1617
- if (!Array.isArray(parsed)) return {
1885
+ const rest = joinedRest(parts);
1886
+ const parsed = parseWire(parts);
1887
+ if (parsed === null) return {
1618
1888
  annotations: [],
1619
1889
  rest
1620
1890
  };
1621
1891
  const annotations = [];
1622
1892
  for (const entry of parsed) {
1623
- if (!entry || typeof entry !== "object") continue;
1624
- const record = entry;
1625
- const body = typeof record.text === "string" ? record.text.trim() : "";
1626
- if (!body) continue;
1627
- const source = record.source && typeof record.source === "object" ? record.source : Array.isArray(record.sources) ? record.sources.find((item) => item && typeof item === "object") : void 0;
1893
+ const item = wireBody(entry);
1894
+ if (item === null) continue;
1895
+ const source = item.sources[0];
1628
1896
  const messageId = typeof source?.messageId === "string" ? source.messageId : void 0;
1629
1897
  const startOffset = typeof source?.startOffset === "number" ? source.startOffset : void 0;
1630
1898
  const endOffset = typeof source?.endOffset === "number" ? source.endOffset : void 0;
1631
1899
  annotations.push({
1632
- title: previewTitle(body),
1633
- text: body,
1634
- comment: typeof record.annotation === "string" ? record.annotation : "",
1900
+ title: previewTitle(item.body),
1901
+ text: item.body,
1902
+ comment: item.comment,
1635
1903
  ...messageId ? { messageId } : {},
1636
1904
  ...startOffset !== void 0 ? { startOffset } : {},
1637
1905
  ...endOffset !== void 0 ? { endOffset } : {}
@@ -1642,6 +1910,33 @@ function scanAnnotatedMessage(text) {
1642
1910
  rest
1643
1911
  };
1644
1912
  }
1913
+ /**
1914
+ * Inverse of `composeAnnotatedMessage` for text that is about to live in the
1915
+ * composer: dsh-rewind refills a withdrawn message verbatim, protocol block
1916
+ * included. Returns the pending annotations and the user's own text, or null
1917
+ * when `text` carries no complete, valid block — a draft that merely mentions
1918
+ * the markers must never be edited away.
1919
+ */
1920
+ function readComposedDraft(text) {
1921
+ const parts = blockParts(text);
1922
+ if (parts === null) return null;
1923
+ const parsed = parseWire(parts);
1924
+ if (parsed === null || parsed.length === 0) return null;
1925
+ const annotations = [];
1926
+ for (const entry of parsed) {
1927
+ const item = wireBody(entry);
1928
+ if (item !== null) annotations.push({
1929
+ text: item.body,
1930
+ comment: item.comment,
1931
+ sources: item.sources
1932
+ });
1933
+ }
1934
+ if (annotations.length === 0) return null;
1935
+ return {
1936
+ annotations,
1937
+ rest: joinedRest(parts)
1938
+ };
1939
+ }
1645
1940
  //#endregion
1646
1941
  //#region src/client/runtime.ts
1647
1942
  /**
@@ -1689,6 +1984,9 @@ function persist(partitionKey) {
1689
1984
  if (!persistence || partitionKey === MEMORY_KEY) return;
1690
1985
  persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
1691
1986
  }
1987
+ function newAnnotationId() {
1988
+ return `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1989
+ }
1692
1990
  /** Append one selection; an identical text+comment pair is reused. */
1693
1991
  function saveAnnotation(sessionId, input) {
1694
1992
  const k = partition(sessionId);
@@ -1697,7 +1995,7 @@ function saveAnnotation(sessionId, input) {
1697
1995
  const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
1698
1996
  if (duplicate) return duplicate;
1699
1997
  const annotation = {
1700
- id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1998
+ id: newAnnotationId(),
1701
1999
  text: input.text,
1702
2000
  ...comment ? { comment } : {},
1703
2001
  sources: input.sources?.length ? input.sources : [{ text: input.text }]
@@ -1707,6 +2005,36 @@ function saveAnnotation(sessionId, input) {
1707
2005
  notify();
1708
2006
  return annotation;
1709
2007
  }
2008
+ /**
2009
+ * Re-adopt annotations lifted out of a composed message.
2010
+ *
2011
+ * dsh-rewind puts a withdrawn message back into the composer verbatim, so the
2012
+ * protocol block arrives there too; the composer half parses it and hands the
2013
+ * items here. Same text+comment pairs already pending win, so a repeated
2014
+ * restore never duplicates a card. Returns how many items were added.
2015
+ */
2016
+ function restoreAnnotations(sessionId, items) {
2017
+ const k = partition(sessionId);
2018
+ const current = bySession.get(k) ?? EMPTY$1;
2019
+ const next = [...current];
2020
+ for (const item of items) {
2021
+ const text = item.text.trim();
2022
+ if (!text) continue;
2023
+ const comment = item.comment?.trim() ?? "";
2024
+ if (next.some((entry) => entry.text === text && (entry.comment ?? "") === comment)) continue;
2025
+ next.push({
2026
+ id: newAnnotationId(),
2027
+ text,
2028
+ ...comment ? { comment } : {},
2029
+ sources: item.sources.length > 0 ? item.sources : [{ text }]
2030
+ });
2031
+ }
2032
+ if (next.length === current.length) return 0;
2033
+ bySession.set(k, next);
2034
+ persist(k);
2035
+ notify();
2036
+ return next.length - current.length;
2037
+ }
1710
2038
  function getAnnotations(sessionId) {
1711
2039
  return bySession.get(partition(sessionId)) ?? EMPTY$1;
1712
2040
  }
@@ -1780,18 +2108,63 @@ function adoptPersistedAnnotations(sessionId) {
1780
2108
  if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
1781
2109
  notify();
1782
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
+ }
1783
2156
  function annotationTitle(annotation) {
1784
2157
  return previewTitle(annotation.text);
1785
2158
  }
1786
2159
  //#endregion
1787
2160
  //#region src/client/dom/popoverPlacement.ts
1788
- const GAP = 8;
2161
+ const GAP$1 = 8;
1789
2162
  const MARGIN = 8;
1790
2163
  /**
1791
2164
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
1792
2165
  * lacks room. Keeps the panel inside `bounds` (the viewport by default).
1793
2166
  */
1794
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
2167
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
1795
2168
  const box = bounds ?? {
1796
2169
  top: 0,
1797
2170
  left: 0,
@@ -1878,6 +2251,7 @@ const zh = {
1878
2251
  "common.selectedText": "选中的文本",
1879
2252
  "common.commentPlaceholder": "添加可选评论…",
1880
2253
  "common.cancel": "取消",
2254
+ "common.close": "关闭",
1881
2255
  "common.save": "保存",
1882
2256
  "toolbar.aria": "划词操作",
1883
2257
  "toolbar.commentHint": "添加批注…",
@@ -1888,26 +2262,26 @@ const zh = {
1888
2262
  "toolbar.commentDialogAria": "添加可选评论",
1889
2263
  "toolbar.skipComment": "暂不评论",
1890
2264
  "toolbar.addComment": "添加评论",
1891
- "card.commentSubtitle": "评论 · {comment}",
1892
- "card.ariaIndexed": "批注 {index}",
1893
- "card.ariaPlain": "划词批注",
1894
- "card.editComment": "编辑评论",
1895
- "card.remove": "移除批注",
1896
2265
  "summary.hint": "悬停查看批注",
2266
+ "summary.hintTouch": "点按查看批注",
1897
2267
  "summary.dialogAria": "{count} 条划词批注详情",
1898
2268
  "summary.edit": "编辑批注 {index}",
1899
2269
  "summary.remove": "移除批注 {index}",
1900
2270
  "summary.userComment": "用户评论",
1901
2271
  "summary.noComment": "未添加评论",
1902
- "summary.aria.other": "{count} 条划词批注。悬停查看详情。",
2272
+ "summary.aria.other": "{count} 条划词批注。展开查看详情。",
1903
2273
  "summary.title.other": "{count} 条批注",
1904
2274
  "summary.removeAll": "移除全部划词批注",
1905
- "chip.annotation": "批注 {index}"
2275
+ "chip.annotation": "批注 {index}",
2276
+ "chip.previewAria": "批注预览",
2277
+ "chip.jump": "原文",
2278
+ "chip.jumpAria": "跳转到原文"
1906
2279
  };
1907
2280
  const en = {
1908
2281
  "common.selectedText": "Selected text",
1909
2282
  "common.commentPlaceholder": "Add an optional comment…",
1910
2283
  "common.cancel": "Cancel",
2284
+ "common.close": "Close",
1911
2285
  "common.save": "Save",
1912
2286
  "toolbar.aria": "Selection actions",
1913
2287
  "toolbar.commentHint": "Add annotation…",
@@ -1918,23 +2292,22 @@ const en = {
1918
2292
  "toolbar.commentDialogAria": "Add an optional comment",
1919
2293
  "toolbar.skipComment": "Skip comment",
1920
2294
  "toolbar.addComment": "Add comment",
1921
- "card.commentSubtitle": "Comment · {comment}",
1922
- "card.ariaIndexed": "Annotation {index}",
1923
- "card.ariaPlain": "Selection annotation",
1924
- "card.editComment": "Edit comment",
1925
- "card.remove": "Remove annotation",
1926
2295
  "summary.hint": "Hover to view annotations",
2296
+ "summary.hintTouch": "Tap to view annotations",
1927
2297
  "summary.dialogAria": "Annotation details: {count}",
1928
2298
  "summary.edit": "Edit annotation {index}",
1929
2299
  "summary.remove": "Remove annotation {index}",
1930
2300
  "summary.userComment": "User comment",
1931
2301
  "summary.noComment": "No comment added",
1932
- "summary.aria.one": "{count} annotation. Hover to view details.",
1933
- "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.",
1934
2304
  "summary.title.one": "{count} annotation",
1935
2305
  "summary.title.other": "{count} annotations",
1936
2306
  "summary.removeAll": "Remove all annotations",
1937
- "chip.annotation": "Annotation {index}"
2307
+ "chip.annotation": "Annotation {index}",
2308
+ "chip.previewAria": "Annotation preview",
2309
+ "chip.jump": "Original",
2310
+ "chip.jumpAria": "Jump to original"
1938
2311
  };
1939
2312
  /**
1940
2313
  * Russian is not a shipped host locale: it is added through
@@ -1945,6 +2318,7 @@ const ru = {
1945
2318
  "common.selectedText": "Выделенный текст",
1946
2319
  "common.commentPlaceholder": "Добавить необязательный комментарий…",
1947
2320
  "common.cancel": "Отмена",
2321
+ "common.close": "Закрыть",
1948
2322
  "common.save": "Сохранить",
1949
2323
  "toolbar.aria": "Действия с выделенным текстом",
1950
2324
  "toolbar.commentHint": "Добавить аннотацию…",
@@ -1955,27 +2329,26 @@ const ru = {
1955
2329
  "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1956
2330
  "toolbar.skipComment": "Без комментария",
1957
2331
  "toolbar.addComment": "Добавить комментарий",
1958
- "card.commentSubtitle": "Комментарий · {comment}",
1959
- "card.ariaIndexed": "Аннотация {index}",
1960
- "card.ariaPlain": "Аннотация к выделенному тексту",
1961
- "card.editComment": "Изменить комментарий",
1962
- "card.remove": "Удалить аннотацию",
1963
2332
  "summary.hint": "Наведите, чтобы увидеть аннотации",
2333
+ "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
1964
2334
  "summary.dialogAria": "Детали аннотаций: {count}",
1965
2335
  "summary.edit": "Изменить аннотацию {index}",
1966
2336
  "summary.remove": "Удалить аннотацию {index}",
1967
2337
  "summary.userComment": "Комментарий пользователя",
1968
2338
  "summary.noComment": "Комментарий не добавлен",
1969
- "summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
1970
- "summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
1971
- "summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1972
- "summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
2339
+ "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2340
+ "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2341
+ "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2342
+ "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
1973
2343
  "summary.title.one": "{count} аннотация",
1974
2344
  "summary.title.few": "{count} аннотации",
1975
2345
  "summary.title.many": "{count} аннотаций",
1976
2346
  "summary.title.other": "{count} аннотаций",
1977
2347
  "summary.removeAll": "Удалить все аннотации",
1978
- "chip.annotation": "Аннотация {index}"
2348
+ "chip.annotation": "Аннотация {index}",
2349
+ "chip.previewAria": "Просмотр аннотации",
2350
+ "chip.jump": "Оригинал",
2351
+ "chip.jumpAria": "Перейти к оригиналу"
1979
2352
  };
1980
2353
  /** Both shipped host locales, required together by the typed `register()`. */
1981
2354
  const SHIPPED_DICTS = Object.freeze({
@@ -2057,56 +2430,18 @@ function useLocale() {
2057
2430
  }
2058
2431
  //#endregion
2059
2432
  //#region src/client/ui/AnnotationCardView.tsx
2060
- function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
2061
- useLocale();
2062
- const composer = variant === "composer";
2063
- const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
2064
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2065
- className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
2066
- role: "group",
2067
- "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
2068
- children: [
2069
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2070
- className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
2071
- "aria-hidden": "true",
2072
- children: index ? String(index) : "AI"
2073
- }),
2074
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2075
- className: cx(A.grow, A.col, styles.cardBody),
2076
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2077
- className: cx(A.ell, styles.cardTitle),
2078
- title: text,
2079
- children: title
2080
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2081
- className: cx(A.ell, styles.cardSubtitle),
2082
- ...comment ? {
2083
- title: comment,
2084
- "data-dsq-has-comment": "true"
2085
- } : {},
2086
- children: subtitle
2087
- })]
2088
- }),
2089
- composer && onEditComment ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2090
- type: "button",
2091
- className: cx(A.plain, A.gridMid, styles.cardAction),
2092
- "aria-label": t("card.editComment"),
2093
- onClick: onEditComment,
2094
- children: "✎"
2095
- }) : null,
2096
- composer && onRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2097
- type: "button",
2098
- className: cx(A.plain, A.gridMid, styles.cardClose),
2099
- "aria-label": t("card.remove"),
2100
- onClick: onRemove,
2101
- children: "×"
2102
- }) : null
2103
- ]
2104
- });
2105
- }
2106
- /** Grace period so pointer transit between the summary and the panel never closes it. */
2107
- const CLOSE_DELAY = 320;
2433
+ /** Short grace for crossing the 12px bridge; the exit starts right after it. */
2434
+ const CLOSE_DELAY$1 = 40;
2108
2435
  /** After a viewport change, wait this long before position-checking the pointer. */
2109
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
+ }
2110
2445
  /**
2111
2446
  * Qoder-style summary with a placement-aware hover list.
2112
2447
  *
@@ -2126,21 +2461,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2126
2461
  const settleTimerRef = (0, react.useRef)(null);
2127
2462
  const lastPointerRef = (0, react.useRef)(null);
2128
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)();
2129
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);
2130
2473
  const [editing, setEditing] = (0, react.useState)(null);
2131
2474
  const [editDraft, setEditDraft] = (0, react.useState)("");
2132
2475
  const place = (0, react.useCallback)(() => {
2133
2476
  const root = rootRef.current;
2134
2477
  const pop = popRef.current;
2135
2478
  if (!root || !pop) return;
2136
- const { top, left, side } = placePopover(root.getBoundingClientRect(), {
2137
- width: pop.offsetWidth || 300,
2138
- 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
2139
2484
  }, "top", 0);
2140
2485
  pop.style.top = `${top}px`;
2141
2486
  pop.style.left = `${left}px`;
2142
- pop.style.paddingTop = side === "bottom" ? "12px" : "12px";
2143
- 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";
2144
2489
  pop.dataset.side = side;
2145
2490
  pop.style.opacity = "1";
2146
2491
  }, []);
@@ -2158,11 +2503,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2158
2503
  cancelClose();
2159
2504
  setOpen(false);
2160
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]);
2161
2529
  const scheduleClose = (0, react.useCallback)(() => {
2162
2530
  cancelClose();
2163
2531
  if (editing !== null) return;
2164
- closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
2532
+ closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
2165
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]);
2166
2581
  /** True when the last seen pointer sits on the summary or the panel. */
2167
2582
  const pointerInsideCluster = (0, react.useCallback)(() => {
2168
2583
  const point = lastPointerRef.current;
@@ -2185,6 +2600,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2185
2600
  items,
2186
2601
  editing
2187
2602
  ]);
2603
+ (0, react.useLayoutEffect)(() => {
2604
+ if (!open || !enterPanelRef.current) return;
2605
+ enterPanelRef.current = false;
2606
+ focusPanel("first");
2607
+ }, [open, focusPanel]);
2188
2608
  (0, react.useEffect)(() => {
2189
2609
  if (!open) return;
2190
2610
  const track = (event) => {
@@ -2239,31 +2659,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2239
2659
  const onPointerEnter = (0, react.useCallback)(() => {
2240
2660
  openPop();
2241
2661
  }, [openPop]);
2242
- const onPointerLeave = (0, react.useCallback)(() => {
2243
- scheduleClose();
2244
- }, [scheduleClose]);
2245
2662
  const onBlur = (0, react.useCallback)((e) => {
2663
+ if (editing !== null) return;
2246
2664
  const next = e.relatedTarget;
2247
2665
  if (next instanceof Node) {
2248
2666
  if (rootRef.current?.contains(next)) return;
2249
2667
  if (popRef.current?.contains(next)) return;
2250
2668
  }
2251
2669
  closePop();
2252
- }, [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]);
2253
2676
  const onKeyDown = (0, react.useCallback)((e) => {
2254
2677
  if (e.key === "Escape") {
2678
+ e.preventDefault();
2679
+ if (editing !== null) setEditing(null);
2680
+ else closeAndRestoreFocus();
2681
+ return;
2682
+ }
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();
2255
2712
  if (editing !== null) setEditing(null);
2256
- else closePop();
2713
+ else closeAndRestoreFocus();
2714
+ return;
2257
2715
  }
2258
- }, [closePop, editing]);
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
+ ]);
2259
2743
  const startEdit = (0, react.useCallback)((index) => {
2260
- setEditing(index);
2261
- setEditDraft(items[index]?.comment ?? "");
2744
+ const item = items[index];
2745
+ if (!item) return;
2746
+ setEditing(editKeyOf(item, index));
2747
+ setEditDraft(item.comment ?? "");
2262
2748
  }, [items]);
2263
2749
  const commitEdit = (0, react.useCallback)(() => {
2264
2750
  if (editing === null) return;
2265
- const item = items[editing];
2266
- 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);
2267
2754
  setEditing(null);
2268
2755
  }, [
2269
2756
  editing,
@@ -2271,19 +2758,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2271
2758
  editDraft,
2272
2759
  onItemEdit
2273
2760
  ]);
2274
- const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2761
+ const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2275
2762
  ref: popRef,
2276
- className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
2763
+ id: panelId,
2764
+ className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
2277
2765
  role: "dialog",
2278
2766
  "aria-label": t("summary.dialogAria", { count }),
2279
2767
  onPointerEnter: openPop,
2280
2768
  onPointerLeave,
2769
+ onKeyDown: onPanelKeyDown,
2281
2770
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2282
2771
  className: cx(A.col, styles.summaryHoverInner),
2283
2772
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2284
2773
  className: cx(A.col, styles.summaryList),
2285
2774
  children: items.map((item, i) => {
2286
- const isEditing = editing === i;
2775
+ const itemKey = editKeyOf(item, i);
2776
+ const isEditing = editing === itemKey;
2287
2777
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2288
2778
  className: styles.summaryItem,
2289
2779
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2319,7 +2809,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2319
2809
  className: styles.summaryItemMain,
2320
2810
  children: [
2321
2811
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2322
- className: styles.annEditText,
2812
+ className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
2323
2813
  children: item.text
2324
2814
  }),
2325
2815
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -2335,6 +2825,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2335
2825
  onKeyDown: (e) => {
2336
2826
  if (e.key === "Escape") {
2337
2827
  e.preventDefault();
2828
+ e.stopPropagation();
2338
2829
  setEditing(null);
2339
2830
  return;
2340
2831
  }
@@ -2353,7 +2844,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2353
2844
  children: t("common.cancel")
2354
2845
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2355
2846
  type: "button",
2356
- className: styles.annEditSave,
2847
+ className: cx(A.plain, styles.annEditSave),
2357
2848
  onClick: commitEdit,
2358
2849
  children: t("common.save")
2359
2850
  })]
@@ -2396,9 +2887,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2396
2887
  "data-selection-annotation-summary": "true",
2397
2888
  "aria-label": t("summary.aria", { count }),
2398
2889
  "aria-expanded": open,
2890
+ "aria-haspopup": "dialog",
2891
+ "aria-controls": open ? panelId : void 0,
2399
2892
  onPointerEnter,
2400
2893
  onPointerLeave,
2401
- onFocus: onPointerEnter,
2894
+ onPointerUp: onSummaryPointerUp,
2895
+ onFocus: onSummaryFocus,
2402
2896
  onBlur,
2403
2897
  onKeyDown,
2404
2898
  children: [
@@ -2409,13 +2903,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2409
2903
  }),
2410
2904
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2411
2905
  className: cx(A.grow, styles.summaryBody),
2412
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2413
- className: styles.summaryTitle,
2414
- children: t("summary.title", { count })
2415
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2416
- className: styles.summaryHint,
2417
- children: t("summary.hint")
2418
- })]
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
+ ]
2419
2920
  }),
2420
2921
  onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2421
2922
  type: "button",
@@ -2464,19 +2965,19 @@ function clearDraftMarker(draft, setDraft) {
2464
2965
  * composer, or the primary send button) so the composer's submit path carries
2465
2966
  * the JSON protocol block.
2466
2967
  */
2467
- function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2968
+ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef }) {
2468
2969
  (0, react.useEffect)(() => {
2469
2970
  if (itemCount === 0) return;
2470
2971
  const inject = () => {
2471
2972
  const current = getAnnotations();
2472
2973
  if (current.length === 0) return;
2974
+ if (foldingRef !== void 0) foldingRef.current = true;
2473
2975
  setDraft(composeAnnotatedMessage(draftRef.current, current));
2474
2976
  clearAnnotations();
2475
2977
  onFolded();
2476
2978
  };
2477
2979
  const onKeyDown = (event) => {
2478
- if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
2479
- if (event.isComposing) return;
2980
+ if (!isSubmitKey(event)) return;
2480
2981
  const target = event.target;
2481
2982
  if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
2482
2983
  const card = composerCardOf(target);
@@ -2505,7 +3006,8 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2505
3006
  itemCount,
2506
3007
  draftRef,
2507
3008
  setDraft,
2508
- onFolded
3009
+ onFolded,
3010
+ foldingRef
2509
3011
  ]);
2510
3012
  }
2511
3013
  //#endregion
@@ -2550,6 +3052,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2550
3052
  const draftRef = (0, react.useRef)(draft);
2551
3053
  const actionsRef = (0, react.useRef)(inputActions);
2552
3054
  const sessionRef = (0, react.useRef)(sessionId);
3055
+ const foldingRef = (0, react.useRef)(false);
2553
3056
  const composing = useComposing();
2554
3057
  draftRef.current = draft;
2555
3058
  actionsRef.current = inputActions;
@@ -2595,8 +3098,26 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2595
3098
  itemCount: items.length,
2596
3099
  draftRef,
2597
3100
  setDraft,
2598
- onFolded: () => setItems(EMPTY)
3101
+ onFolded: () => setItems(EMPTY),
3102
+ foldingRef
2599
3103
  });
3104
+ (0, react.useLayoutEffect)(() => {
3105
+ if (!draft.includes("<response-annotations>")) {
3106
+ foldingRef.current = false;
3107
+ return;
3108
+ }
3109
+ if (foldingRef.current || composing.current) return;
3110
+ const restored = readComposedDraft(draft);
3111
+ if (restored === null) return;
3112
+ foldingRef.current = true;
3113
+ restoreAnnotations(sessionId, restored.annotations);
3114
+ setDraft(restored.rest);
3115
+ }, [
3116
+ draft,
3117
+ sessionId,
3118
+ setDraft,
3119
+ composing
3120
+ ]);
2600
3121
  (0, react.useEffect)(() => {
2601
3122
  if (composing.current) return;
2602
3123
  if (items.length === 0) {
@@ -2679,10 +3200,47 @@ const QuoteCard = AnnotationPanel;
2679
3200
  const HOST_ATTR = "data-dsq-layer";
2680
3201
  /** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
2681
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;
2682
3209
  const layers = /* @__PURE__ */ new WeakMap();
2683
3210
  /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2684
3211
  const itemLayers = /* @__PURE__ */ new WeakMap();
2685
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
+ }
3232
+ /**
3233
+ * Top edge of the composer seat, in viewport coordinates.
3234
+ *
3235
+ * In the desktop (overlay) layout the composer is `position: sticky` over the
3236
+ * scroller and stacks above this layer (z 7 vs 6), so the scroller's own box
3237
+ * extends **behind** it. Placement that stops at the scroller box therefore
3238
+ * parks popovers under the input box — the bottom edge must be the composer.
3239
+ * In the regular layout the seat sits below the scroller, so it changes nothing.
3240
+ */
3241
+ function composerTop(scroller) {
3242
+ return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
3243
+ }
2686
3244
  function contentCoords(scroller, left, top) {
2687
3245
  const rect = scroller.getBoundingClientRect();
2688
3246
  return {
@@ -2706,7 +3264,11 @@ function layerFor(scroller) {
2706
3264
  scroller,
2707
3265
  bounds: () => {
2708
3266
  const rect = scroller.getBoundingClientRect();
2709
- return new DOMRect(rect.left + scroller.clientLeft, rect.top + scroller.clientTop, scroller.clientWidth, scroller.clientHeight);
3267
+ const top = rect.top + scroller.clientTop;
3268
+ const rawBottom = top + scroller.clientHeight;
3269
+ const cover = composerTop(scroller);
3270
+ const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
3271
+ return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
2710
3272
  },
2711
3273
  toContent: (left, top) => contentCoords(scroller, left, top),
2712
3274
  fromContent: (left, top) => {
@@ -2715,6 +3277,9 @@ function layerFor(scroller) {
2715
3277
  left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
2716
3278
  top: top + rect.top + scroller.clientTop - scroller.scrollTop
2717
3279
  };
3280
+ },
3281
+ setAboveComposer: (above) => {
3282
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
2718
3283
  }
2719
3284
  };
2720
3285
  layers.set(scroller, layer);
@@ -2730,7 +3295,10 @@ function fallbackLayer() {
2730
3295
  fallback = {
2731
3296
  host,
2732
3297
  scroller: document.body,
2733
- bounds: () => new DOMRect(0, 0, window.innerWidth, window.innerHeight),
3298
+ bounds: () => {
3299
+ const bottom = composerTop(null) ?? window.innerHeight;
3300
+ return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
3301
+ },
2734
3302
  toContent: (left, top) => ({
2735
3303
  left,
2736
3304
  top
@@ -2738,7 +3306,10 @@ function fallbackLayer() {
2738
3306
  fromContent: (left, top) => ({
2739
3307
  left,
2740
3308
  top
2741
- })
3309
+ }),
3310
+ setAboveComposer: (above) => {
3311
+ host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
3312
+ }
2742
3313
  };
2743
3314
  return fallback;
2744
3315
  }
@@ -2923,6 +3494,69 @@ function linkTarget(text, anchorHref) {
2923
3494
  return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
2924
3495
  }
2925
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
2926
3560
  //#region src/client/SelectionToolbar.tsx
2927
3561
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2928
3562
  const COMMENT_MAX_HEIGHT = 220;
@@ -2939,6 +3573,8 @@ function SelectionToolbar({ sessionId }) {
2939
3573
  activeRef.current = active;
2940
3574
  /** Annotation saved when the comment step opened; the box only edits it. */
2941
3575
  const pendingIdRef = (0, react.useRef)(null);
3576
+ const touch = useCoarsePointer();
3577
+ const viewport = useVisualViewport(touch);
2942
3578
  (0, react.useEffect)(() => {
2943
3579
  ensureToolbarStyles();
2944
3580
  if (sessionId) bindAnnotationSession(sessionId);
@@ -3056,6 +3692,7 @@ function SelectionToolbar({ sessionId }) {
3056
3692
  refresh
3057
3693
  ]);
3058
3694
  const placeCommentPop = (0, react.useCallback)(() => {
3695
+ if (touch) return;
3059
3696
  const snapshot = activeRef.current;
3060
3697
  const layer = snapshot?.layer;
3061
3698
  const toolbar = toolbarRef.current;
@@ -3100,33 +3737,46 @@ function SelectionToolbar({ sessionId }) {
3100
3737
  const toolbarAt = layer.toContent(leftView, topView);
3101
3738
  toolbar.style.left = `${toolbarAt.left}px`;
3102
3739
  toolbar.style.top = `${toolbarAt.top}px`;
3103
- }, []);
3740
+ }, [touch]);
3104
3741
  (0, react.useLayoutEffect)(() => {
3105
- if (phase === "comment") placeCommentPop();
3742
+ if (phase === "comment" && !touch) placeCommentPop();
3106
3743
  }, [
3107
3744
  active,
3108
3745
  phase,
3109
- 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
3110
3758
  ]);
3111
3759
  (0, react.useEffect)(() => {
3112
- if (phase === "comment") commentRef.current?.focus();
3113
- }, [phase]);
3760
+ if (phase === "comment" && !touch) commentRef.current?.focus();
3761
+ }, [phase, touch]);
3114
3762
  (0, react.useEffect)(() => {
3763
+ if (touch) return;
3115
3764
  const onResize = () => placeCommentPop();
3116
3765
  window.addEventListener("resize", onResize);
3117
3766
  return () => window.removeEventListener("resize", onResize);
3118
- }, [placeCommentPop]);
3767
+ }, [placeCommentPop, touch]);
3119
3768
  (0, react.useLayoutEffect)(() => {
3120
3769
  if (phase !== "comment") return;
3121
3770
  const area = commentRef.current;
3122
3771
  if (!area) return;
3123
3772
  area.style.height = "auto";
3124
3773
  area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
3125
- placeCommentPop();
3774
+ if (!touch) placeCommentPop();
3126
3775
  }, [
3127
3776
  phase,
3128
3777
  comment,
3129
- placeCommentPop
3778
+ placeCommentPop,
3779
+ touch
3130
3780
  ]);
3131
3781
  const handleToolbarPointerDown = (0, react.useCallback)((event) => {
3132
3782
  event.preventDefault();
@@ -3178,14 +3828,147 @@ function SelectionToolbar({ sessionId }) {
3178
3828
  else pendingIdRef.current = null;
3179
3829
  clearNativeSelection();
3180
3830
  hide();
3181
- 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();
3182
3841
  }, [flushComment, hide]);
3183
3842
  if (!active) return null;
3184
3843
  const toolbarStyle = {
3185
3844
  left: active.left,
3186
3845
  top: active.top
3187
3846
  };
3188
- 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
+ }
3189
3972
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3190
3973
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3191
3974
  ref: toolbarRef,
@@ -3207,45 +3990,9 @@ function SelectionToolbar({ sessionId }) {
3207
3990
  },
3208
3991
  children: t("toolbar.commentHint")
3209
3992
  })
3210
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3993
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3211
3994
  className: cx(A.inlineRow, styles.toolbarInner),
3212
- children: [
3213
- link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3214
- type: "button",
3215
- className: cx(A.plain, A.inlineRow, styles.button),
3216
- onClick: handleOpenLink,
3217
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3218
- name: "external-link",
3219
- size: 14
3220
- }), t("toolbar.openLink")]
3221
- }) : null,
3222
- link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3223
- className: styles.divider,
3224
- "aria-hidden": "true"
3225
- }) : null,
3226
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3227
- type: "button",
3228
- className: cx(A.plain, A.inlineRow, styles.button),
3229
- onClick: () => void handleCopy(),
3230
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3231
- name: "copy",
3232
- size: 14
3233
- }), t("toolbar.copy")]
3234
- }),
3235
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3236
- className: styles.divider,
3237
- "aria-hidden": "true"
3238
- }),
3239
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3240
- type: "button",
3241
- className: cx(A.plain, A.inlineRow, styles.button),
3242
- onClick: openComment,
3243
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3244
- name: "plus",
3245
- size: 14
3246
- }), t("toolbar.addToTask")]
3247
- })
3248
- ]
3995
+ children: actionRow(false)
3249
3996
  })
3250
3997
  }),
3251
3998
  phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -3254,39 +4001,7 @@ function SelectionToolbar({ sessionId }) {
3254
4001
  role: "dialog",
3255
4002
  "aria-label": t("toolbar.commentDialogAria"),
3256
4003
  onPointerDown: handleToolbarPointerDown,
3257
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3258
- ref: commentRef,
3259
- className: cx(A.box, styles.commentInput),
3260
- placeholder: t("common.commentPlaceholder"),
3261
- value: comment,
3262
- onChange: (e) => setComment(e.target.value),
3263
- onKeyDown: (e) => {
3264
- if (e.key === "Escape") {
3265
- e.preventDefault();
3266
- pendingIdRef.current = null;
3267
- hide();
3268
- return;
3269
- }
3270
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3271
- e.preventDefault();
3272
- finishComment(true);
3273
- }
3274
- }
3275
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3276
- className: styles.commentActions,
3277
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3278
- type: "button",
3279
- className: cx(A.plain, A.inlineRow, styles.button),
3280
- onClick: () => finishComment(false),
3281
- children: t("toolbar.skipComment")
3282
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3283
- type: "button",
3284
- className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3285
- disabled: !comment.trim(),
3286
- onClick: () => finishComment(true),
3287
- children: t("toolbar.addComment")
3288
- })]
3289
- })]
4004
+ children: [commentField, commentActions]
3290
4005
  }) : null,
3291
4006
  status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3292
4007
  className: styles.status,
@@ -3297,6 +4012,423 @@ function SelectionToolbar({ sessionId }) {
3297
4012
  ] }), active.layer.host);
3298
4013
  }
3299
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
3300
4432
  //#region src/client/dom/decorateAnnotationDirectives.ts
3301
4433
  /**
3302
4434
  * Turn model-written `:dsh-annotation{index="N"}` into clickable chips.
@@ -3309,7 +4441,7 @@ function decorateTextNode(node) {
3309
4441
  if (!value || !value.includes(":dsh-annotation{")) return;
3310
4442
  const parent = node.parentElement;
3311
4443
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
3312
- if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
4444
+ if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
3313
4445
  if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
3314
4446
  const frag = document.createDocumentFragment();
3315
4447
  let last = 0;
@@ -3326,10 +4458,7 @@ function decorateTextNode(node) {
3326
4458
  chip.setAttribute("data-dsq-deco", "true");
3327
4459
  chip.dataset[DECO_KEY] = String(index);
3328
4460
  chip.textContent = t("chip.annotation", { index });
3329
- chip.addEventListener("click", () => {
3330
- const mark = findAnnotationMarkBefore(index, chip);
3331
- if (mark) flashAnnotationMark(mark);
3332
- });
4461
+ bindChipPreview(chip, index);
3333
4462
  frag.append(chip);
3334
4463
  last = m.index + m[0].length;
3335
4464
  }
@@ -3359,6 +4488,7 @@ function decorateAnnotationDirectives(root) {
3359
4488
  * literal, so `decorateTextNode()` returns before it can touch the DOM.
3360
4489
  */
3361
4490
  function refreshDirectiveChips(root) {
4491
+ closeChipPreview(false);
3362
4492
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
3363
4493
  if (!el) return;
3364
4494
  for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
@@ -3389,16 +4519,158 @@ function watchAnnotationDirectives(root) {
3389
4519
  return () => observer.disconnect();
3390
4520
  }
3391
4521
  //#endregion
4522
+ //#region src/client/dom/stripSubmissionEcho.ts
4523
+ /**
4524
+ * Strip the annotation protocol from host surfaces that paint the raw draft.
4525
+ *
4526
+ * On submit the host paints an optimistic bubble (`[data-submission-echo]`)
4527
+ * from the composer draft — which, at that instant, is the protocol block the
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.
4534
+ *
4535
+ * The durable message keeps the block (the model needs it). This is display
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.
4540
+ */
4541
+ const ECHO_SELECTOR = "[data-submission-echo]";
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("*"))) {
4552
+ if (el.children.length > 0) continue;
4553
+ if (carriesProtocol(el.textContent ?? "")) runs.push(el);
4554
+ }
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));
4586
+ }
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;
4602
+ }
4603
+ /**
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.
4607
+ */
4608
+ function watchSubmissionEcho(root) {
4609
+ const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
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
+ };
4616
+ const sweep = () => {
4617
+ for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
4618
+ };
4619
+ sweep();
4620
+ if (typeof MutationObserver === "undefined") return () => {};
4621
+ const pending = /* @__PURE__ */ new Set();
4622
+ let frame = 0;
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;
4645
+ if (frame !== 0) return;
4646
+ frame = window.requestAnimationFrame(() => {
4647
+ frame = 0;
4648
+ for (const surface of pending) stripSurface(surface);
4649
+ pending.clear();
4650
+ });
4651
+ });
4652
+ observer.observe(scope, {
4653
+ childList: true,
4654
+ subtree: true,
4655
+ characterData: true
4656
+ });
4657
+ return () => {
4658
+ observer.disconnect();
4659
+ pending.clear();
4660
+ if (frame !== 0) window.cancelAnimationFrame(frame);
4661
+ };
4662
+ }
4663
+ //#endregion
3392
4664
  //#region src/client/transcript/TranscriptQuoteCard.tsx
3393
4665
  /**
3394
- * History-side cards for one user message's annotations.
4666
+ * History-side summary for one user message's annotations.
3395
4667
  *
3396
- * One card per annotation, each showing the selected text **and** the user's
3397
- * comment. The comment is how the user recognizes their own annotation, so it
3398
- * belongs on the visible card — not behind a hover panel that has to be
3399
- * discovered. Clicking a card scrolls back to the marked text in the source
3400
- * message; the ordinal badge matches the `:dsh-annotation{index="N"}` the model
3401
- * refers to.
4668
+ * One block per message, not one card per annotation: the block carries the
4669
+ * count and the hover list shows every annotation with its selected text and
4670
+ * comment. That is the same shape the composer uses for pending selections, so
4671
+ * a message reads the same before and after it is sent. Clicking an item
4672
+ * scrolls back to the marked text in the source message; the ordinal badge
4673
+ * matches the `:dsh-annotation{index="N"}` the model refers to.
3402
4674
  */
3403
4675
  function TranscriptQuoteCard({ node }) {
3404
4676
  ensureToolbarStyles();
@@ -3416,98 +4688,157 @@ function TranscriptQuoteCard({ node }) {
3416
4688
  endOffset: quote.endOffset
3417
4689
  })));
3418
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]);
3419
4697
  const markIdOf = (0, react.useCallback)((index) => {
3420
4698
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
3421
4699
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
3422
4700
  }, []);
3423
- const onSelect = (0, react.useCallback)((index, text) => {
3424
- scrollToAnnotation(index + 1, text, markIdOf(index));
3425
- }, [markIdOf]);
4701
+ const onSelect = (0, react.useCallback)((index, _item) => {
4702
+ const quote = quotes[index];
4703
+ if (quote === void 0) return;
4704
+ scrollToAnnotation(index + 1, quote.text, markIdOf(index));
4705
+ }, [markIdOf, quotes]);
3426
4706
  if (quotes.length === 0) return null;
3427
4707
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3428
4708
  ref: stackRef,
3429
- className: styles.tCardStack,
4709
+ className: styles.tSummary,
3430
4710
  "data-dsq-deco": "true",
3431
- children: quotes.map((quote, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3432
- className: cx(A.plain, styles.tCardButton),
3433
- role: "button",
3434
- tabIndex: 0,
3435
- "aria-label": quote.title,
3436
- onClick: () => onSelect(index, quote.text),
3437
- onKeyDown: (event) => {
3438
- if (event.key !== "Enter" && event.key !== " ") return;
3439
- event.preventDefault();
3440
- onSelect(index, quote.text);
3441
- },
3442
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationCardView, {
3443
- variant: "transcript",
4711
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
4712
+ count: quotes.length,
4713
+ items: quotes.map((quote, index) => ({
4714
+ id: `dqsm-${index}`,
3444
4715
  title: quote.title,
3445
4716
  text: quote.text,
3446
- comment: quote.comment || void 0,
3447
- index: index + 1
3448
- })
3449
- }, `ann-${index}`))
4717
+ ...quote.comment ? { comment: quote.comment } : {}
4718
+ })),
4719
+ onItemSelect: onSelect
4720
+ })
3450
4721
  });
3451
4722
  }
3452
4723
  //#endregion
3453
4724
  //#region src/client/transcript/UserMessageDisplay.tsx
3454
- function contentImages(content) {
3455
- if (!Array.isArray(content)) return [];
4725
+ function isTextBlock(block) {
4726
+ if (block === null || typeof block !== "object") return false;
4727
+ const candidate = block;
4728
+ if ((candidate.type === "image" || candidate.type === "file") && candidate.attachment !== void 0) return false;
4729
+ return candidate.type === "text" || typeof candidate.text === "string";
4730
+ }
4731
+ /**
4732
+ * Replace all text blocks with one stripped text block, keeping attachments and
4733
+ * unknown blocks in place. The host's `contentParts` joins text blocks and
4734
+ * lifts attachments out anyway, so one text block is the same message content
4735
+ * with the protocol block gone.
4736
+ */
4737
+ function strippedContent(raw, text) {
4738
+ const rest = Array.isArray(raw) ? raw.filter((block) => !isTextBlock(block)) : [];
4739
+ if (text) rest.unshift({
4740
+ type: "text",
4741
+ text
4742
+ });
4743
+ return rest;
4744
+ }
4745
+ /** Image attachments for the factory-less fallback bubble. */
4746
+ function imageAttachments(raw) {
4747
+ if (!Array.isArray(raw)) return [];
3456
4748
  const images = [];
3457
- for (const block of content) {
4749
+ for (const block of raw) {
3458
4750
  if (block === null || typeof block !== "object") continue;
3459
4751
  const candidate = block;
3460
- if (candidate.type !== "image" || candidate.attachment === void 0) continue;
3461
- images.push({ attachment: candidate.attachment });
4752
+ if (candidate.type === "image" && candidate.attachment !== void 0) images.push(candidate.attachment);
3462
4753
  }
3463
4754
  return images;
3464
4755
  }
3465
4756
  /**
3466
4757
  * Replacement for the built-in `user` Chat node view.
3467
4758
  *
3468
- * Strips the `<response-annotations>` protocol block from the bubble **and**
3469
- * renders one card per annotation right above it — the same order the composer
3470
- * shows them in, so the sent message reads like the draft did.
4759
+ * It does **not** redraw the bubble or its actions row. It resolves the host's
4760
+ * own `UserMessageNodeView` from `ctx.slots.entries('conversation.chat.node')`
4761
+ * and renders that component with a rewritten `node`: the annotation protocol
4762
+ * block is removed from the text blocks, everything else (attachments, file
4763
+ * cards, reference labels, time, copy, and any plugin that portals into the
4764
+ * actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
4765
+ * The annotation summary is the only extra DOM, prepended above the host row.
3471
4766
  *
3472
- * Why the cards live here instead of on a `select-quote` Chat node:
3473
- * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
3474
- * allowlist (system-prompt, user, steering, turn-trigger, turn-process,
3475
- * turn-error, turn-max-tokens, turn-tail). A node whose kind is not on that list
3476
- * is classified as a turn-process member and `hidden` inside the collapsed
3477
- * process disclosure of a completed turn — mounted, then invisible. `user` is on
3478
- * the list, so rendering the cards inside this component is the only placement
3479
- * that survives turn folding.
3480
- */
3481
- function UserMessageDisplay({ node, renderMessageImages }) {
4767
+ * The summary must live inside the `user` node view rather than on a
4768
+ * `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
4769
+ * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
4770
+ * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
4771
+ * other kind is `hidden` when the completed turn's process disclosure folds.
4772
+ */
4773
+ function UserMessageDisplay(props) {
3482
4774
  ensureToolbarStyles();
4775
+ const { node, hostNodeOf, ...rest } = props;
3483
4776
  const raw = contentBlocksToText(node.data?.content);
3484
4777
  const scanned = scanAnnotatedMessage(raw);
3485
- const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
4778
+ const text = displayTextWithoutQuote(scanned.rest).trim();
3486
4779
  const quotes = scanned.annotations;
3487
- const images = contentImages(node.data?.content);
3488
- if (!text && images.length === 0 && quotes.length === 0) return null;
3489
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4780
+ const rewritten = quotes.length > 0 || text !== raw.trim();
4781
+ const displayNode = (0, react.useMemo)(() => rewritten ? {
4782
+ ...node,
4783
+ data: {
4784
+ ...node.data,
4785
+ content: strippedContent(node.data?.content, text)
4786
+ }
4787
+ } : node, [
4788
+ node,
4789
+ rewritten,
4790
+ text
4791
+ ]);
4792
+ const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
4793
+ const Host = hostNodeOf?.();
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, {
4795
+ ...rest,
4796
+ node: displayNode
4797
+ })] });
4798
+ const images = imageAttachments(node.data?.content);
4799
+ const loadImage = props.loadImage;
4800
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3490
4801
  className: cx(A.colEnd, styles.userRow),
3491
4802
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3492
4803
  className: cx(A.colEnd, styles.userStack),
3493
- children: [
3494
- images.length > 0 && renderMessageImages !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3495
- className: cx(A.colEnd, styles.userImages),
3496
- "data-message-attachments": true,
3497
- children: renderMessageImages({
3498
- images,
3499
- align: "end",
3500
- compact: images.length > 1
3501
- })
3502
- }) : null,
3503
- quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null,
3504
- text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3505
- className: styles.userBubble,
3506
- children: text
3507
- }) : null
3508
- ]
4804
+ "data-dsq-fallback-content": "true",
4805
+ children: [images.length > 0 && loadImage !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4806
+ className: cx(A.colEnd, styles.userImages),
4807
+ "data-message-attachments": true,
4808
+ children: images.map((attachment, index) => {
4809
+ const url = loadImage(attachment);
4810
+ return url === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4811
+ src: url,
4812
+ alt: ""
4813
+ }, `image-${index}`);
4814
+ })
4815
+ }) : null, text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4816
+ className: styles.userBubble,
4817
+ children: text
4818
+ }) : null]
3509
4819
  })
3510
- });
4820
+ })] });
4821
+ }
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;
3511
4842
  }
3512
4843
  //#endregion
3513
4844
  //#region src/client/state/annotation-persistence.ts
@@ -3666,7 +4997,7 @@ function createAnnotationPersistence() {
3666
4997
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
3667
4998
  * are always observed. Annotation summary stays on the session composer.
3668
4999
  *
3669
- * 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
3670
5001
  * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
3671
5002
  * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3672
5003
  * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
@@ -3701,6 +5032,7 @@ function apply(ctx) {
3701
5032
  ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
3702
5033
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
3703
5034
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
5035
+ ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
3704
5036
  ctx.effect(() => {
3705
5037
  syncPendingAnnotationJobs(getAnnotations());
3706
5038
  return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
@@ -3709,7 +5041,20 @@ function apply(ctx) {
3709
5041
  const port = createAnnotationPersistence();
3710
5042
  setAnnotationPersistence(port);
3711
5043
  port.prune();
3712
- 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
+ };
3713
5058
  }, "dsh-select-quote: annotation persistence");
3714
5059
  ctx.slots.inject("shell.overlay", () => {
3715
5060
  ctx.slots.register({
@@ -3732,12 +5077,28 @@ function apply(ctx) {
3732
5077
  order: 1
3733
5078
  }, SessionBridge);
3734
5079
  });
3735
- ctx.slots.inject("conversation.chat.node", () => {
5080
+ const hostNodeOf = (key, self) => {
5081
+ return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
5082
+ };
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", () => {
3736
5096
  ctx.slots.register({
3737
5097
  name: "conversation.chat.node",
3738
- key: "user",
3739
- priority: -10
3740
- }, UserMessageDisplay);
5098
+ key,
5099
+ priority: -10,
5100
+ locale: "chat"
5101
+ }, View);
3741
5102
  });
3742
5103
  }
3743
5104
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */