dsh-select-quote 0.3.6 → 0.3.8

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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/lib/client.js +800 -571
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -5,8 +5,8 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
- let react_jsx_runtime = require("react/jsx-runtime");
9
8
  let react_dom = require("react-dom");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
10
  //#region src/client/dom/composer-host.ts
11
11
  /**
12
12
  * Host DOM contract for the resident composer.
@@ -138,6 +138,28 @@ ${atomRules}
138
138
  }
139
139
  }
140
140
 
141
+ /* Same entrance for the below-anchor variant (only the content top uses it). */
142
+ @keyframes dsq-toolbar-in-below {
143
+ from {
144
+ transform: translate(-50%, 0) translateY(-4px) scale(0.96);
145
+ }
146
+ to {
147
+ transform: translate(-50%, 0) translateY(0) scale(1);
148
+ }
149
+ }
150
+
151
+ /* Comment phase: the hint sits above the popover's top-left corner (explicit
152
+ top-left placement comes from placeCommentPop), so the entrance no longer
153
+ owns the centering shift. */
154
+ @keyframes dsq-toolbar-in-popover-top {
155
+ from {
156
+ transform: translateY(4px) scale(0.96);
157
+ }
158
+ to {
159
+ transform: translateY(0) scale(1);
160
+ }
161
+ }
162
+
141
163
  /* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
142
164
  @keyframes dsq-rise-in {
143
165
  from {
@@ -184,10 +206,35 @@ ${atomRules}
184
206
  --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));
185
207
  }
186
208
 
209
+ /* 浮层挂载与层带(2026-10-20 定,改前先读):
210
+ 工具条/评论泡/状态不再 portal 到 body,而是由 dom/floating-layer.ts 挂进对话
211
+ 真正的滚动容器(桌面抽屉布局是 ST7X_W_scrollBody,普通布局是 .icaHSq_scroll)
212
+ 里的一个 absolute host,坐标用**内容坐标**。
213
+
214
+ 为什么不是 body:header(对话标题那一排)是滚动容器之外的非定位元素,任何
215
+ body 级 positioned + z-index 的浮层都天然压过它(这就是 z-index 降到 0 也仍在
216
+ header 之上的原因);composer 座位是 sticky + z-index 7,只有低于它才不会被
217
+ 输入框穿透。
218
+
219
+ 现在把两件事交回宿主布局:
220
+ - 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
221
+ 下面,和正文一致;
222
+ - host 的 z-index 固定 6(见 floating-layer.ts):低于 composer 的 7,高于
223
+ 内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
224
+ host 内部次序:status 3 > toolbar 2 > commentPop 1。
225
+
226
+ 摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
227
+ body 级 fixed(原因见 AnnotationCardView 里 contain:layout 的说明)。 */
228
+
187
229
  .dsq_toolbar {
188
- position: fixed;
189
- z-index: 10060;
190
- transform: translate(-50%, -100%);
230
+ position: absolute;
231
+ z-index: 2;
232
+ /* The layer host is 0×0, so an auto-width absolute child would shrink to
233
+ min-content (Chinese hint text then wraps one character per line).
234
+ max-content keeps the bubble sized by its row regardless of the host. */
235
+ width: max-content;
236
+ max-width: calc(100vw - 24px);
237
+ transform: translate(-50%, var(--dsq-anchor-y, -100%));
191
238
  gap: 2px;
192
239
  padding: 4px;
193
240
  /* 无边框:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的描边环,
@@ -202,6 +249,18 @@ ${atomRules}
202
249
  animation: dsq-toolbar-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
203
250
  }
204
251
 
252
+ /* Hangs below the selection: no room above at the very top of the transcript. */
253
+ .dsq_toolbar[data-anchor='below'] {
254
+ --dsq-anchor-y: 0%;
255
+ animation-name: dsq-toolbar-in-below;
256
+ }
257
+
258
+ /* Comment phase: placed at an explicit top-left, 6px above the comment popover. */
259
+ .dsq_toolbar[data-anchor='popover-top'] {
260
+ transform: none;
261
+ animation-name: dsq-toolbar-in-popover-top;
262
+ }
263
+
205
264
  /* 按钮行:只负责布局;入场动画在容器上(见 .dsq_toolbar 的 A/B 说明)。 */
206
265
  .dsq_toolbarInner {
207
266
  display: inline-flex;
@@ -248,8 +307,8 @@ ${atomRules}
248
307
  }
249
308
 
250
309
  .dsq_status {
251
- position: fixed;
252
- z-index: 10061;
310
+ position: absolute;
311
+ z-index: 3;
253
312
  transform: translate(-50%, 8px);
254
313
  padding: 4px 10px;
255
314
  border-radius: 999px;
@@ -266,7 +325,7 @@ ${atomRules}
266
325
  position: absolute;
267
326
  top: 8px;
268
327
  left: 8px;
269
- z-index: 40;
328
+ z-index: 12;
270
329
  width: 20%;
271
330
  min-width: 148px;
272
331
  max-width: 220px;
@@ -275,78 +334,8 @@ ${atomRules}
275
334
  pointer-events: auto;
276
335
  }
277
336
 
278
- /* One annotation card, shared by the composer and the transcript: the base
279
- carries the shape, and \`.dsq_tCard\` / \`.dsq_tCardIcon\` override only the
280
- numbers where the transcript really differs.
281
- Geometry mirrors the product's own file card (\`.nyYjTG_file\` in
282
- ui-deliverables): hairline border, neutral fill, 18px radius, fixed 72px row. */
283
- .dsq_card {
284
- --dsq-card-fill: var(--dsq-neutral-50);
285
- --dsq-card-hover: var(--dsq-neutral-100);
286
- flex: 0 0 auto;
287
- gap: 10px;
288
- width: fit-content;
289
- max-width: calc(25% - 5px);
290
- min-width: 0;
291
- height: 64px;
292
- margin: 0;
293
- padding: 10px;
294
- border: 0.5px solid var(--dsq-line);
295
- border-radius: 18px;
296
- background: var(--dsq-card-fill);
297
- color: var(--dsq-label);
298
- transition: background-color 0.12s;
299
- position: relative;
300
- overflow: hidden;
301
- user-select: none;
302
- pointer-events: auto;
303
- }
304
-
305
- .dsq_card:hover {
306
- background: var(--dsq-card-hover);
307
- }
308
-
309
- /* Scaled down from the reference 48px: the composer card is width-capped at a
310
- quarter of the input, so 48px would leave no room for the quote text. */
311
- .dsq_cardIcon {
312
- z-index: 2;
313
- width: 36px;
314
- height: 36px;
315
- border: 0.5px solid var(--dsq-line);
316
- border-radius: 12px;
317
- background: var(--dsq-card-fill);
318
- color: var(--dsq-link);
319
- font-size: 12px;
320
- font-weight: 600;
321
- letter-spacing: -0.02em;
322
- }
323
-
324
- .dsq_cardBody {
325
- z-index: 2;
326
- justify-content: center;
327
- gap: 2px;
328
- }
329
-
330
- /* Keeps the ellipsised title clear of the corner remove button, which only the
331
- composer stack renders. */
332
- .dsq_cardStack .dsq_cardBody {
333
- padding-right: 14px;
334
- }
335
-
336
- .dsq_cardTitle {
337
- font-size: 14px;
338
- font-weight: 500;
339
- line-height: 22px;
340
- color: var(--dsq-label);
341
- }
342
-
343
- .dsq_cardSubtitle {
344
- font-size: 12px;
345
- line-height: 18px;
346
- color: var(--dsq-label-3);
347
- }
348
-
349
- /* Corner remove button: hidden until the card is hovered or focused. */
337
+ /* Corner remove-all button on the summary block: hidden until the block is
338
+ hovered or focused. */
350
339
  .dsq_cardClose {
351
340
  position: absolute;
352
341
  top: 4px;
@@ -363,8 +352,6 @@ ${atomRules}
363
352
  transition: opacity 0.12s, background-color 0.12s;
364
353
  }
365
354
 
366
- .dsq_card:hover .dsq_cardClose,
367
- .dsq_card:focus-within .dsq_cardClose,
368
355
  .dsq_summary:hover .dsq_cardClose,
369
356
  .dsq_summary:focus-within .dsq_cardClose {
370
357
  opacity: 1;
@@ -376,37 +363,10 @@ ${atomRules}
376
363
  color: var(--dsq-label);
377
364
  }
378
365
 
379
- /* Secondary corner action (edit comment). */
380
- .dsq_cardAction {
381
- position: absolute;
382
- top: 4px;
383
- right: 28px;
384
- z-index: 3;
385
- width: 20px;
386
- height: 20px;
387
- border-radius: 6px;
388
- color: var(--dsq-label-3);
389
- font-size: 12px;
390
- line-height: 1;
391
- opacity: 0;
392
- pointer-events: none;
393
- transition: opacity 0.12s, background-color 0.12s;
394
- }
395
-
396
- .dsq_card:hover .dsq_cardAction,
397
- .dsq_card:focus-within .dsq_cardAction {
398
- opacity: 1;
399
- pointer-events: auto;
400
- }
401
-
402
- .dsq_cardAction:hover {
403
- background: var(--dsq-fill-hover);
404
- }
405
-
406
366
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
407
367
  .dsq_summary {
408
368
  position: relative;
409
- z-index: 10040;
369
+ z-index: 13;
410
370
  gap: 10px;
411
371
  width: 100%;
412
372
  max-width: 100%;
@@ -450,7 +410,7 @@ ${atomRules}
450
410
  flex atoms — the open class below turns display back on. */
451
411
  display: none;
452
412
  position: fixed;
453
- z-index: 10050;
413
+ z-index: 14;
454
414
  width: min(320px, calc(100vw - 16px));
455
415
  flex-direction: column;
456
416
  padding: 12px;
@@ -671,15 +631,19 @@ ${atomRules}
671
631
 
672
632
  /* Inline comment composer under the floating selection toolbar. */
673
633
  .dsq_commentPop {
674
- position: fixed;
675
- z-index: 10050;
676
- transform: translate(-50%, 4px);
634
+ position: absolute;
635
+ z-index: 1;
636
+ /* Vertical offset comes from placeCommentPop (below the selection); only
637
+ the horizontal centering belongs to the transform. */
638
+ transform: translateX(-50%);
677
639
  width: min(320px, calc(100vw - 24px));
678
640
  gap: 8px;
679
641
  padding: 8px;
680
642
  border-radius: 16px;
681
643
  /* 底色与模糊都在材质图层里 */
682
644
  box-shadow: var(--dsq-shadow);
645
+ /* The layer host is pointer-events:none; interactive surfaces opt back in. */
646
+ pointer-events: auto;
683
647
  }
684
648
 
685
649
  .dsq_commentInput {
@@ -707,98 +671,19 @@ ${atomRules}
707
671
  gap: 6px;
708
672
  }
709
673
 
710
- /* Transcript cards — in-flow rows under the user bubble, side by side. */
711
- .dsq_tCardStack {
674
+ /* Transcript summary — the same block as the composer's, right-aligned under
675
+ the user bubble. One block per message; the hover list carries every
676
+ annotation with its selected text and comment. */
677
+ .dsq_tSummary {
712
678
  display: flex;
713
- flex-direction: row;
714
- flex-wrap: wrap;
715
679
  justify-content: flex-end;
716
- align-items: stretch;
717
- gap: 6px;
718
680
  margin: 4px 0 8px;
719
- /* Cards lay out horizontally and wrap onto the next row when they run out of
720
- width. Each card carries the selected text plus the user's comment, so they
721
- stay compact and stay on the user-bubble side (right). */
722
- max-width: 100%;
723
- margin-left: auto;
724
- margin-right: 0;
725
681
  }
726
682
 
727
- /* Transcript-only differences from the shared card. Unlike the composer
728
- summary (one collapsed row per message) each history card shows its own
729
- comment, so the fixed height and single-line clamps are lifted on \`.dsq_tCardButton\`. */
730
- .dsq_tCard {
683
+ .dsq_tSummary .dsq_summary {
684
+ width: auto;
685
+ min-width: 220px;
731
686
  max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
732
- margin-left: auto;
733
- }
734
-
735
- .dsq_tCardIcon {
736
- width: 40px;
737
- height: 40px;
738
- font-size: 14px;
739
- }
740
-
741
- .dsq_tCardButton {
742
- /* Compact cards: several fit in one row before wrapping. */
743
- display: block;
744
- flex: 0 1 auto;
745
- width: 230px;
746
- max-width: 100%;
747
- padding: 0;
748
- font: inherit;
749
- text-align: left;
750
- border-radius: 14px;
751
- }
752
-
753
- .dsq_tCardButton:focus-visible {
754
- outline: 2px solid var(--dsq-accent);
755
- outline-offset: 2px;
756
- }
757
-
758
- .dsq_tCardButton .dsq_card {
759
- height: auto;
760
- min-height: 0;
761
- align-items: flex-start;
762
- width: 100%;
763
- gap: 8px;
764
- padding: 8px;
765
- border-radius: 14px;
766
- }
767
-
768
- .dsq_tCardButton .dsq_cardIcon {
769
- width: 22px;
770
- height: 22px;
771
- border-radius: 8px;
772
- font-size: 11px;
773
- }
774
-
775
- .dsq_tCardButton .dsq_cardTitle {
776
- display: -webkit-box;
777
- -webkit-line-clamp: 2;
778
- -webkit-box-orient: vertical;
779
- overflow: hidden;
780
- white-space: normal;
781
- overflow-wrap: anywhere;
782
- font-size: 13px;
783
- line-height: 18px;
784
- }
785
-
786
- .dsq_tCardButton .dsq_cardSubtitle {
787
- display: -webkit-box;
788
- -webkit-line-clamp: 3;
789
- -webkit-box-orient: vertical;
790
- overflow: hidden;
791
- white-space: pre-wrap;
792
- overflow-wrap: anywhere;
793
- margin-top: 2px;
794
- font-size: 12px;
795
- line-height: 16px;
796
- }
797
-
798
- /* A comment is the user's own words — give it more presence than the neutral
799
- "no comment" placeholder. */
800
- .dsq_cardSubtitle[data-dsq-has-comment='true'] {
801
- color: var(--dsq-label-2);
802
687
  }
803
688
 
804
689
  /* Replacement user bubble (quote block stripped from display). */
@@ -829,6 +714,12 @@ ${atomRules}
829
714
  max-width: 100%;
830
715
  }
831
716
 
717
+ .dsq_userImages img {
718
+ display: block;
719
+ max-width: 100%;
720
+ border-radius: 12px;
721
+ }
722
+
832
723
  /* Model-written :dsh-annotation{index="N"} chips. */
833
724
  .dsq_annDirective {
834
725
  appearance: none;
@@ -849,6 +740,7 @@ ${atomRules}
849
740
  .dsq_annMark {
850
741
  --dsq-mark-color: var(--dsq-accent);
851
742
  --dsq-mark-tint: var(--dsq-accent-tint);
743
+ position: relative;
852
744
  background: var(--dsq-mark-tint);
853
745
  text-decoration: underline;
854
746
  text-decoration-style: dashed;
@@ -862,10 +754,17 @@ ${atomRules}
862
754
 
863
755
  /* Ordinal as a superscript corner badge at the END of the marked text, so it
864
756
  never sits between the reader and the sentence. Only the last fragment of a
865
- multi-fragment mark carries the index, so the number appears exactly once. */
757
+ multi-fragment mark carries the index, so the number appears exactly once.
758
+ Absolute so the badge adds no inline advance width: annotating never reflows
759
+ the paragraph or moves following words. Bottom (not top) anchors to the last
760
+ fragment: for a mark spanning several lines the containing block is the union
761
+ of the first and last fragments, so its bottom edge is the last line. */
866
762
  .dsq_annMark[data-dsq-ann-index]::after {
867
763
  content: attr(data-dsq-ann-index);
868
- display: inline-grid;
764
+ position: absolute;
765
+ bottom: 8px;
766
+ left: 100%;
767
+ display: grid;
869
768
  place-items: center;
870
769
  min-width: 15px;
871
770
  height: 15px;
@@ -877,7 +776,7 @@ ${atomRules}
877
776
  font-size: 10px;
878
777
  font-weight: 600;
879
778
  line-height: 1;
880
- vertical-align: super;
779
+ pointer-events: none;
881
780
  }
882
781
 
883
782
  .dsq_annMark:hover {
@@ -903,13 +802,7 @@ ${atomRules}
903
802
  }
904
803
 
905
804
  /* The product flips its palette on body[data-ds-dark-theme], and every
906
- --dsq-* alias follows it. Only values we own ourselves need restating — the
907
- transcript card carries .dsq_card too, so one selector covers both. */
908
- body[data-ds-dark-theme] .dsq_card {
909
- --dsq-card-fill: var(--dsq-neutral-850);
910
- --dsq-card-hover: var(--dsq-neutral-800);
911
- }
912
-
805
+ --dsq-* alias follows it. Only values we own ourselves need restating. */
913
806
  body[data-ds-dark-theme] .dsq_annMark {
914
807
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
915
808
  }
@@ -938,14 +831,8 @@ const styles = {
938
831
  divider: "dsq_divider",
939
832
  status: "dsq_status",
940
833
  cardStack: "dsq_cardStack",
941
- card: "dsq_card",
942
- cardIcon: "dsq_cardIcon",
943
- cardBody: "dsq_cardBody",
944
- cardTitle: "dsq_cardTitle",
945
- cardSubtitle: "dsq_cardSubtitle",
946
834
  cardClose: "dsq_cardClose",
947
835
  cardPad: "dsq_cardPad",
948
- cardAction: "dsq_cardAction",
949
836
  summary: "dsq_summary",
950
837
  summaryIcon: "dsq_summaryIcon",
951
838
  summaryBody: "dsq_summaryBody",
@@ -977,10 +864,7 @@ const styles = {
977
864
  commentPop: "dsq_commentPop",
978
865
  commentInput: "dsq_commentInput",
979
866
  commentActions: "dsq_commentActions",
980
- tCardStack: "dsq_tCardStack",
981
- tCardButton: "dsq_tCardButton",
982
- tCard: "dsq_tCard",
983
- tCardIcon: "dsq_tCardIcon",
867
+ tSummary: "dsq_tSummary",
984
868
  userRow: "dsq_userRow",
985
869
  userStack: "dsq_userStack",
986
870
  userImages: "dsq_userImages",
@@ -1163,6 +1047,12 @@ function createMark(job, withIndex) {
1163
1047
  * selections that cross inline elements or paragraph boundaries never reorder
1164
1048
  * the DOM; fragments of one annotation share the mark id, and the ordinal
1165
1049
  * badge rides on the final fragment (end of the annotation).
1050
+ *
1051
+ * `surroundContents` is load-bearing: `extractContents()` leaves the text
1052
+ * before **and after** the range in the same node, so inserting the mark at
1053
+ * `node.nextSibling` (the old implementation) appended every partial selection
1054
+ * to the end of its parent — a selection starting at offset 0, such as the
1055
+ * sentence after a colon, visibly jumped to the paragraph's end.
1166
1056
  */
1167
1057
  function wrapRange(item, job, from, to) {
1168
1058
  const located = cleanTextRange(item, from, to);
@@ -1172,15 +1062,12 @@ function wrapRange(item, job, from, to) {
1172
1062
  let wrapped = false;
1173
1063
  pieces.forEach((piece, i) => {
1174
1064
  const node = piece.entry.node;
1175
- const parent = node.parentElement;
1176
- if (!parent) return;
1065
+ if (!node.parentElement) return;
1177
1066
  const range = document.createRange();
1178
1067
  range.setStart(node, piece.start - piece.entry.start);
1179
1068
  range.setEnd(node, piece.end - piece.entry.start);
1180
1069
  const mark = createMark(job, i === pieces.length - 1);
1181
- mark.append(range.extractContents());
1182
- parent.insertBefore(mark, node.nextSibling);
1183
- if (node.nodeValue === "") node.remove();
1070
+ range.surroundContents(mark);
1184
1071
  wrapped = true;
1185
1072
  });
1186
1073
  return wrapped;
@@ -1517,47 +1404,80 @@ function stripProtocolHeader(prefix) {
1517
1404
  if (prefix.indexOf("# Response annotations:") >= 0) return "";
1518
1405
  return prefix.replace(/\s+$/, "");
1519
1406
  }
1407
+ function blockParts(text) {
1408
+ const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1409
+ if (open < 0) return null;
1410
+ const close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, open + 22);
1411
+ if (close < 0) return null;
1412
+ return {
1413
+ before: stripProtocolHeader(text.slice(0, open)),
1414
+ after: text.slice(close + 23).replace(/^\s+/, ""),
1415
+ json: text.slice(open + 22, close).trim()
1416
+ };
1417
+ }
1418
+ function joinedRest(parts) {
1419
+ return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
1420
+ }
1421
+ /** Wire payload as an array; null when the block is not valid JSON. */
1422
+ function parseWire(parts) {
1423
+ let parsed;
1424
+ try {
1425
+ parsed = JSON.parse(parts.json);
1426
+ } catch {
1427
+ return null;
1428
+ }
1429
+ return Array.isArray(parsed) ? parsed : null;
1430
+ }
1431
+ /** Every source an item carries; falls back to the selected text itself. */
1432
+ function sourcesOf(record, body) {
1433
+ const single = record.source;
1434
+ if (single !== null && typeof single === "object" && typeof single.text === "string") return [single];
1435
+ if (Array.isArray(record.sources)) {
1436
+ const list = record.sources.filter((item) => item !== null && typeof item === "object" && typeof item.text === "string");
1437
+ if (list.length > 0) return list;
1438
+ }
1439
+ return [{ text: body }];
1440
+ }
1441
+ /** One usable wire item; null for entries without a selected text. */
1442
+ function wireBody(entry) {
1443
+ if (entry === null || typeof entry !== "object") return null;
1444
+ const record = entry;
1445
+ const body = typeof record.text === "string" ? record.text.trim() : "";
1446
+ if (!body) return null;
1447
+ return {
1448
+ body,
1449
+ comment: typeof record.annotation === "string" ? record.annotation : "",
1450
+ sources: sourcesOf(record, body)
1451
+ };
1452
+ }
1520
1453
  /**
1521
1454
  * Read plugin annotations out of a durable message. Tolerates the JSON block
1522
1455
  * and strips it from `rest` so the bubble can show only the user's question.
1523
1456
  */
1524
1457
  function scanAnnotatedMessage(text) {
1525
- const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
1526
- const close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE);
1527
- if (open < 0 || close < 0 || close < open) return {
1458
+ const parts = blockParts(text);
1459
+ if (parts === null) return {
1528
1460
  annotations: [],
1529
1461
  rest: text
1530
1462
  };
1531
- const jsonStart = open + 22;
1532
- const jsonText = text.slice(jsonStart, close).trim();
1533
- const rest = [stripProtocolHeader(text.slice(0, open)), text.slice(close + 23).replace(/^\s+/, "")].filter((part) => part.length > 0).join("\n\n").trim();
1534
- let parsed;
1535
- try {
1536
- parsed = JSON.parse(jsonText);
1537
- } catch {
1538
- return {
1539
- annotations: [],
1540
- rest: text.replace(/^\s+/, "")
1541
- };
1542
- }
1543
- if (!Array.isArray(parsed)) return {
1463
+ const parsed = parseWire(parts);
1464
+ if (parsed === null) return {
1544
1465
  annotations: [],
1545
- rest
1466
+ rest: text.replace(/^\s+/, "")
1546
1467
  };
1468
+ const rest = joinedRest(parts);
1547
1469
  const annotations = [];
1548
1470
  for (const entry of parsed) {
1549
- if (!entry || typeof entry !== "object") continue;
1550
- const record = entry;
1551
- const body = typeof record.text === "string" ? record.text.trim() : "";
1552
- if (!body) continue;
1553
- const source = record.source && typeof record.source === "object" ? record.source : Array.isArray(record.sources) ? record.sources.find((item) => item && typeof item === "object") : void 0;
1471
+ const item = wireBody(entry);
1472
+ if (item === null) continue;
1473
+ const source = item.sources[0];
1554
1474
  const messageId = typeof source?.messageId === "string" ? source.messageId : void 0;
1555
1475
  const startOffset = typeof source?.startOffset === "number" ? source.startOffset : void 0;
1556
1476
  const endOffset = typeof source?.endOffset === "number" ? source.endOffset : void 0;
1557
1477
  annotations.push({
1558
- title: previewTitle(body),
1559
- text: body,
1560
- comment: typeof record.annotation === "string" ? record.annotation : "",
1478
+ title: previewTitle(item.body),
1479
+ text: item.body,
1480
+ comment: item.comment,
1561
1481
  ...messageId ? { messageId } : {},
1562
1482
  ...startOffset !== void 0 ? { startOffset } : {},
1563
1483
  ...endOffset !== void 0 ? { endOffset } : {}
@@ -1568,6 +1488,33 @@ function scanAnnotatedMessage(text) {
1568
1488
  rest
1569
1489
  };
1570
1490
  }
1491
+ /**
1492
+ * Inverse of `composeAnnotatedMessage` for text that is about to live in the
1493
+ * composer: dsh-rewind refills a withdrawn message verbatim, protocol block
1494
+ * included. Returns the pending annotations and the user's own text, or null
1495
+ * when `text` carries no complete, valid block — a draft that merely mentions
1496
+ * the markers must never be edited away.
1497
+ */
1498
+ function readComposedDraft(text) {
1499
+ const parts = blockParts(text);
1500
+ if (parts === null) return null;
1501
+ const parsed = parseWire(parts);
1502
+ if (parsed === null || parsed.length === 0) return null;
1503
+ const annotations = [];
1504
+ for (const entry of parsed) {
1505
+ const item = wireBody(entry);
1506
+ if (item !== null) annotations.push({
1507
+ text: item.body,
1508
+ comment: item.comment,
1509
+ sources: item.sources
1510
+ });
1511
+ }
1512
+ if (annotations.length === 0) return null;
1513
+ return {
1514
+ annotations,
1515
+ rest: joinedRest(parts)
1516
+ };
1517
+ }
1571
1518
  //#endregion
1572
1519
  //#region src/client/runtime.ts
1573
1520
  /**
@@ -1615,6 +1562,9 @@ function persist(partitionKey) {
1615
1562
  if (!persistence || partitionKey === MEMORY_KEY) return;
1616
1563
  persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
1617
1564
  }
1565
+ function newAnnotationId() {
1566
+ return `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1567
+ }
1618
1568
  /** Append one selection; an identical text+comment pair is reused. */
1619
1569
  function saveAnnotation(sessionId, input) {
1620
1570
  const k = partition(sessionId);
@@ -1623,7 +1573,7 @@ function saveAnnotation(sessionId, input) {
1623
1573
  const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
1624
1574
  if (duplicate) return duplicate;
1625
1575
  const annotation = {
1626
- id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1576
+ id: newAnnotationId(),
1627
1577
  text: input.text,
1628
1578
  ...comment ? { comment } : {},
1629
1579
  sources: input.sources?.length ? input.sources : [{ text: input.text }]
@@ -1633,6 +1583,36 @@ function saveAnnotation(sessionId, input) {
1633
1583
  notify();
1634
1584
  return annotation;
1635
1585
  }
1586
+ /**
1587
+ * Re-adopt annotations lifted out of a composed message.
1588
+ *
1589
+ * dsh-rewind puts a withdrawn message back into the composer verbatim, so the
1590
+ * protocol block arrives there too; the composer half parses it and hands the
1591
+ * items here. Same text+comment pairs already pending win, so a repeated
1592
+ * restore never duplicates a card. Returns how many items were added.
1593
+ */
1594
+ function restoreAnnotations(sessionId, items) {
1595
+ const k = partition(sessionId);
1596
+ const current = bySession.get(k) ?? EMPTY$1;
1597
+ const next = [...current];
1598
+ for (const item of items) {
1599
+ const text = item.text.trim();
1600
+ if (!text) continue;
1601
+ const comment = item.comment?.trim() ?? "";
1602
+ if (next.some((entry) => entry.text === text && (entry.comment ?? "") === comment)) continue;
1603
+ next.push({
1604
+ id: newAnnotationId(),
1605
+ text,
1606
+ ...comment ? { comment } : {},
1607
+ sources: item.sources.length > 0 ? item.sources : [{ text }]
1608
+ });
1609
+ }
1610
+ if (next.length === current.length) return 0;
1611
+ bySession.set(k, next);
1612
+ persist(k);
1613
+ notify();
1614
+ return next.length - current.length;
1615
+ }
1636
1616
  function getAnnotations(sessionId) {
1637
1617
  return bySession.get(partition(sessionId)) ?? EMPTY$1;
1638
1618
  }
@@ -1715,20 +1695,24 @@ const GAP = 8;
1715
1695
  const MARGIN = 8;
1716
1696
  /**
1717
1697
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
1718
- * lacks room. Keeps the panel inside the viewport on the horizontal axis.
1698
+ * lacks room. Keeps the panel inside `bounds` (the viewport by default).
1719
1699
  */
1720
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP) {
1721
- const vw = window.innerWidth;
1722
- const vh = window.innerHeight;
1723
- const spaceBelow = vh - anchor.bottom;
1724
- const spaceAbove = anchor.top;
1700
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
1701
+ const box = bounds ?? {
1702
+ top: 0,
1703
+ left: 0,
1704
+ right: window.innerWidth,
1705
+ bottom: window.innerHeight
1706
+ };
1707
+ const spaceBelow = box.bottom - anchor.bottom;
1708
+ const spaceAbove = anchor.top - box.top;
1725
1709
  let side = preferred;
1726
1710
  const need = panel.height + gap;
1727
1711
  if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
1728
1712
  else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
1729
1713
  return {
1730
- top: side === "bottom" ? Math.min(anchor.bottom + gap, vh - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, MARGIN),
1731
- left: Math.min(Math.max(anchor.left, MARGIN), Math.max(MARGIN, vw - panel.width - MARGIN)),
1714
+ top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
1715
+ left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
1732
1716
  side
1733
1717
  };
1734
1718
  }
@@ -1746,78 +1730,32 @@ const SVG_ATTRS = {
1746
1730
  focusable: false
1747
1731
  };
1748
1732
  const SHAPES = {
1749
- copy: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("rect", {
1750
- key: "r",
1733
+ copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1751
1734
  width: 14,
1752
1735
  height: 14,
1753
1736
  x: 8,
1754
1737
  y: 8,
1755
1738
  rx: 2,
1756
1739
  ry: 2
1757
- }), (0, react_jsx_runtime.jsx)("path", {
1758
- key: "p",
1759
- d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
1760
- })] }),
1761
- pencil: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1762
- key: "p1",
1763
- d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
1764
- }), (0, react_jsx_runtime.jsx)("path", {
1765
- key: "p2",
1766
- d: "m15 5 4 4"
1767
- })] }),
1768
- "trash-2": (0, react_jsx_runtime.jsxs)("g", { children: [
1769
- (0, react_jsx_runtime.jsx)("path", {
1770
- key: "p1",
1771
- d: "M10 11v6"
1772
- }),
1773
- (0, react_jsx_runtime.jsx)("path", {
1774
- key: "p2",
1775
- d: "M14 11v6"
1776
- }),
1777
- (0, react_jsx_runtime.jsx)("path", {
1778
- key: "p3",
1779
- d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
1780
- }),
1781
- (0, react_jsx_runtime.jsx)("path", {
1782
- key: "p4",
1783
- d: "M3 6h18"
1784
- }),
1785
- (0, react_jsx_runtime.jsx)("path", {
1786
- key: "p5",
1787
- d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
1788
- })
1740
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
1741
+ pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
1742
+ "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
1743
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
1744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
1745
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
1746
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
1747
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
1789
1748
  ] }),
1790
- x: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1791
- key: "p1",
1792
- d: "M18 6 6 18"
1793
- }), (0, react_jsx_runtime.jsx)("path", {
1794
- key: "p2",
1795
- d: "m6 6 12 12"
1796
- })] }),
1797
- plus: (0, react_jsx_runtime.jsxs)("g", { children: [(0, react_jsx_runtime.jsx)("path", {
1798
- key: "p1",
1799
- d: "M5 12h14"
1800
- }), (0, react_jsx_runtime.jsx)("path", {
1801
- key: "p2",
1802
- d: "M12 5v14"
1803
- })] }),
1804
- "external-link": (0, react_jsx_runtime.jsxs)("g", { children: [
1805
- (0, react_jsx_runtime.jsx)("path", {
1806
- key: "p1",
1807
- d: "M15 3h6v6"
1808
- }),
1809
- (0, react_jsx_runtime.jsx)("path", {
1810
- key: "p2",
1811
- d: "M10 14 21 3"
1812
- }),
1813
- (0, react_jsx_runtime.jsx)("path", {
1814
- key: "p3",
1815
- d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
1816
- })
1749
+ x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
1750
+ plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
1751
+ "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
1752
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
1753
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
1754
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
1817
1755
  ] })
1818
1756
  };
1819
1757
  function LucideIcon({ name, size = 15 }) {
1820
- return (0, react_jsx_runtime.jsx)("svg", {
1758
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1821
1759
  ...SVG_ATTRS,
1822
1760
  width: size,
1823
1761
  height: size,
@@ -1856,11 +1794,6 @@ const zh = {
1856
1794
  "toolbar.commentDialogAria": "添加可选评论",
1857
1795
  "toolbar.skipComment": "暂不评论",
1858
1796
  "toolbar.addComment": "添加评论",
1859
- "card.commentSubtitle": "评论 · {comment}",
1860
- "card.ariaIndexed": "批注 {index}",
1861
- "card.ariaPlain": "划词批注",
1862
- "card.editComment": "编辑评论",
1863
- "card.remove": "移除批注",
1864
1797
  "summary.hint": "悬停查看批注",
1865
1798
  "summary.dialogAria": "{count} 条划词批注详情",
1866
1799
  "summary.edit": "编辑批注 {index}",
@@ -1886,11 +1819,6 @@ const en = {
1886
1819
  "toolbar.commentDialogAria": "Add an optional comment",
1887
1820
  "toolbar.skipComment": "Skip comment",
1888
1821
  "toolbar.addComment": "Add comment",
1889
- "card.commentSubtitle": "Comment · {comment}",
1890
- "card.ariaIndexed": "Annotation {index}",
1891
- "card.ariaPlain": "Selection annotation",
1892
- "card.editComment": "Edit comment",
1893
- "card.remove": "Remove annotation",
1894
1822
  "summary.hint": "Hover to view annotations",
1895
1823
  "summary.dialogAria": "Annotation details: {count}",
1896
1824
  "summary.edit": "Edit annotation {index}",
@@ -1923,11 +1851,6 @@ const ru = {
1923
1851
  "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1924
1852
  "toolbar.skipComment": "Без комментария",
1925
1853
  "toolbar.addComment": "Добавить комментарий",
1926
- "card.commentSubtitle": "Комментарий · {comment}",
1927
- "card.ariaIndexed": "Аннотация {index}",
1928
- "card.ariaPlain": "Аннотация к выделенному тексту",
1929
- "card.editComment": "Изменить комментарий",
1930
- "card.remove": "Удалить аннотацию",
1931
1854
  "summary.hint": "Наведите, чтобы увидеть аннотации",
1932
1855
  "summary.dialogAria": "Детали аннотаций: {count}",
1933
1856
  "summary.edit": "Изменить аннотацию {index}",
@@ -2025,52 +1948,6 @@ function useLocale() {
2025
1948
  }
2026
1949
  //#endregion
2027
1950
  //#region src/client/ui/AnnotationCardView.tsx
2028
- function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
2029
- useLocale();
2030
- const composer = variant === "composer";
2031
- const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
2032
- return (0, react_jsx_runtime.jsxs)("div", {
2033
- className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
2034
- role: "group",
2035
- "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
2036
- children: [
2037
- (0, react_jsx_runtime.jsx)("div", {
2038
- className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
2039
- "aria-hidden": true,
2040
- children: index ? String(index) : "AI"
2041
- }),
2042
- (0, react_jsx_runtime.jsxs)("div", {
2043
- className: cx(A.grow, A.col, styles.cardBody),
2044
- children: [(0, react_jsx_runtime.jsx)("div", {
2045
- className: cx(A.ell, styles.cardTitle),
2046
- title: text,
2047
- children: title
2048
- }), (0, react_jsx_runtime.jsx)("div", {
2049
- className: cx(A.ell, styles.cardSubtitle),
2050
- ...comment ? {
2051
- title: comment,
2052
- "data-dsq-has-comment": "true"
2053
- } : {},
2054
- children: subtitle
2055
- })]
2056
- }),
2057
- composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
2058
- type: "button",
2059
- className: cx(A.plain, A.gridMid, styles.cardAction),
2060
- "aria-label": t("card.editComment"),
2061
- onClick: onEditComment,
2062
- children: "✎"
2063
- }) : null,
2064
- composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
2065
- type: "button",
2066
- className: cx(A.plain, A.gridMid, styles.cardClose),
2067
- "aria-label": t("card.remove"),
2068
- onClick: onRemove,
2069
- children: "×"
2070
- }) : null
2071
- ]
2072
- });
2073
- }
2074
1951
  /** Grace period so pointer transit between the summary and the panel never closes it. */
2075
1952
  const CLOSE_DELAY = 320;
2076
1953
  /** After a viewport change, wait this long before position-checking the pointer. */
@@ -2239,62 +2116,62 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2239
2116
  editDraft,
2240
2117
  onItemEdit
2241
2118
  ]);
2242
- const panel = open ? (0, react_jsx_runtime.jsx)("div", {
2119
+ const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2243
2120
  ref: popRef,
2244
2121
  className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
2245
2122
  role: "dialog",
2246
2123
  "aria-label": t("summary.dialogAria", { count }),
2247
2124
  onPointerEnter: openPop,
2248
2125
  onPointerLeave,
2249
- children: (0, react_jsx_runtime.jsx)("div", {
2126
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2250
2127
  className: cx(A.col, styles.summaryHoverInner),
2251
- children: (0, react_jsx_runtime.jsx)("div", {
2128
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2252
2129
  className: cx(A.col, styles.summaryList),
2253
2130
  children: items.map((item, i) => {
2254
2131
  const isEditing = editing === i;
2255
- return (0, react_jsx_runtime.jsxs)("div", {
2132
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2256
2133
  className: styles.summaryItem,
2257
- children: [(0, react_jsx_runtime.jsxs)("div", {
2134
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2258
2135
  className: cx(A.row, styles.summaryItemHead),
2259
2136
  children: [
2260
- (0, react_jsx_runtime.jsx)("div", {
2137
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2261
2138
  className: cx(A.keep, A.gridMid, styles.summaryItemBadge),
2262
- "aria-hidden": true,
2139
+ "aria-hidden": "true",
2263
2140
  children: String(i + 1)
2264
2141
  }),
2265
- (0, react_jsx_runtime.jsx)("div", {
2142
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2266
2143
  className: styles.summaryItemLabel,
2267
2144
  children: t("common.selectedText")
2268
2145
  }),
2269
- !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
2146
+ !isEditing && (onItemRemove || onItemEdit) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2270
2147
  className: cx(A.row, styles.summaryItemActions),
2271
- children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
2148
+ children: [onItemEdit ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2272
2149
  type: "button",
2273
2150
  className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2274
2151
  "aria-label": t("summary.edit", { index: i + 1 }),
2275
2152
  onClick: () => startEdit(i),
2276
- children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2277
- }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
2153
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2154
+ }) : null, onItemRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2278
2155
  type: "button",
2279
2156
  className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2280
2157
  "aria-label": t("summary.remove", { index: i + 1 }),
2281
2158
  onClick: () => onItemRemove(i, item),
2282
- children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
2159
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
2283
2160
  }) : null]
2284
2161
  }) : null
2285
2162
  ]
2286
- }), isEditing ? (0, react_jsx_runtime.jsxs)("div", {
2163
+ }), isEditing ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2287
2164
  className: styles.summaryItemMain,
2288
2165
  children: [
2289
- (0, react_jsx_runtime.jsx)("div", {
2166
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2290
2167
  className: styles.annEditText,
2291
2168
  children: item.text
2292
2169
  }),
2293
- (0, react_jsx_runtime.jsx)("div", {
2170
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2294
2171
  className: styles.summaryItemLabel,
2295
2172
  children: t("summary.userComment")
2296
2173
  }),
2297
- (0, react_jsx_runtime.jsx)("textarea", {
2174
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
2298
2175
  ref: editInputRef,
2299
2176
  className: cx(A.box, styles.annEditInput),
2300
2177
  placeholder: t("common.commentPlaceholder"),
@@ -2312,14 +2189,14 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2312
2189
  }
2313
2190
  }
2314
2191
  }),
2315
- (0, react_jsx_runtime.jsxs)("div", {
2192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2316
2193
  className: cx(A.row, styles.annEditActions),
2317
- children: [(0, react_jsx_runtime.jsx)("button", {
2194
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2318
2195
  type: "button",
2319
2196
  className: cx(A.plain, styles.annEditCancel),
2320
2197
  onClick: () => setEditing(null),
2321
2198
  children: t("common.cancel")
2322
- }), (0, react_jsx_runtime.jsx)("button", {
2199
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2323
2200
  type: "button",
2324
2201
  className: styles.annEditSave,
2325
2202
  onClick: commitEdit,
@@ -2327,9 +2204,9 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2327
2204
  })]
2328
2205
  })
2329
2206
  ]
2330
- }) : (0, react_jsx_runtime.jsx)("div", {
2207
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2331
2208
  className: styles.summaryItemMain,
2332
- children: (0, react_jsx_runtime.jsxs)("button", {
2209
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2333
2210
  type: "button",
2334
2211
  className: cx(A.plain, styles.summaryItemButton),
2335
2212
  onClick: () => {
@@ -2337,15 +2214,15 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2337
2214
  closePop();
2338
2215
  },
2339
2216
  children: [
2340
- (0, react_jsx_runtime.jsx)("div", {
2217
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2341
2218
  className: styles.summaryItemText,
2342
2219
  children: item.title
2343
2220
  }),
2344
- (0, react_jsx_runtime.jsx)("div", {
2221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2345
2222
  className: styles.summaryItemLabel,
2346
2223
  children: t("summary.userComment")
2347
2224
  }),
2348
- (0, react_jsx_runtime.jsx)("div", {
2225
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2349
2226
  className: item.comment ? styles.summaryItemText : `${styles.summaryItemText} ${styles.summaryItemTextEmpty}`,
2350
2227
  children: item.comment || t("summary.noComment")
2351
2228
  })
@@ -2357,7 +2234,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2357
2234
  })
2358
2235
  })
2359
2236
  }) : null;
2360
- return (0, react_jsx_runtime.jsxs)("div", {
2237
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2361
2238
  ref: rootRef,
2362
2239
  className: cx(A.row, styles.summary),
2363
2240
  tabIndex: 0,
@@ -2370,27 +2247,27 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2370
2247
  onBlur,
2371
2248
  onKeyDown,
2372
2249
  children: [
2373
- (0, react_jsx_runtime.jsx)("div", {
2250
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2374
2251
  className: cx(A.keep, A.gridMid, styles.summaryIcon),
2375
- "aria-hidden": true,
2252
+ "aria-hidden": "true",
2376
2253
  children: String(count)
2377
2254
  }),
2378
- (0, react_jsx_runtime.jsxs)("div", {
2255
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2379
2256
  className: cx(A.grow, styles.summaryBody),
2380
- children: [(0, react_jsx_runtime.jsx)("div", {
2257
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2381
2258
  className: styles.summaryTitle,
2382
2259
  children: t("summary.title", { count })
2383
- }), (0, react_jsx_runtime.jsx)("div", {
2260
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2384
2261
  className: styles.summaryHint,
2385
2262
  children: t("summary.hint")
2386
2263
  })]
2387
2264
  }),
2388
- onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2265
+ onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2389
2266
  type: "button",
2390
2267
  className: cx(A.plain, A.gridMid, styles.cardClose),
2391
2268
  "aria-label": t("summary.removeAll"),
2392
2269
  onClick: onRemoveAll,
2393
- children: (0, react_jsx_runtime.jsx)(LucideIcon, {
2270
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
2394
2271
  name: "x",
2395
2272
  size: 13
2396
2273
  })
@@ -2432,12 +2309,13 @@ function clearDraftMarker(draft, setDraft) {
2432
2309
  * composer, or the primary send button) so the composer's submit path carries
2433
2310
  * the JSON protocol block.
2434
2311
  */
2435
- function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2312
+ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef }) {
2436
2313
  (0, react.useEffect)(() => {
2437
2314
  if (itemCount === 0) return;
2438
2315
  const inject = () => {
2439
2316
  const current = getAnnotations();
2440
2317
  if (current.length === 0) return;
2318
+ if (foldingRef !== void 0) foldingRef.current = true;
2441
2319
  setDraft(composeAnnotatedMessage(draftRef.current, current));
2442
2320
  clearAnnotations();
2443
2321
  onFolded();
@@ -2473,7 +2351,8 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2473
2351
  itemCount,
2474
2352
  draftRef,
2475
2353
  setDraft,
2476
- onFolded
2354
+ onFolded,
2355
+ foldingRef
2477
2356
  ]);
2478
2357
  }
2479
2358
  //#endregion
@@ -2518,6 +2397,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2518
2397
  const draftRef = (0, react.useRef)(draft);
2519
2398
  const actionsRef = (0, react.useRef)(inputActions);
2520
2399
  const sessionRef = (0, react.useRef)(sessionId);
2400
+ const foldingRef = (0, react.useRef)(false);
2521
2401
  const composing = useComposing();
2522
2402
  draftRef.current = draft;
2523
2403
  actionsRef.current = inputActions;
@@ -2563,8 +2443,26 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2563
2443
  itemCount: items.length,
2564
2444
  draftRef,
2565
2445
  setDraft,
2566
- onFolded: () => setItems(EMPTY)
2446
+ onFolded: () => setItems(EMPTY),
2447
+ foldingRef
2567
2448
  });
2449
+ (0, react.useLayoutEffect)(() => {
2450
+ if (!draft.includes("<response-annotations>")) {
2451
+ foldingRef.current = false;
2452
+ return;
2453
+ }
2454
+ if (foldingRef.current || composing.current) return;
2455
+ const restored = readComposedDraft(draft);
2456
+ if (restored === null) return;
2457
+ foldingRef.current = true;
2458
+ restoreAnnotations(sessionId, restored.annotations);
2459
+ setDraft(restored.rest);
2460
+ }, [
2461
+ draft,
2462
+ sessionId,
2463
+ setDraft,
2464
+ composing
2465
+ ]);
2568
2466
  (0, react.useEffect)(() => {
2569
2467
  if (composing.current) return;
2570
2468
  if (items.length === 0) {
@@ -2605,10 +2503,10 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2605
2503
  sync();
2606
2504
  }, [sync]);
2607
2505
  if (!sessionId || items.length === 0) return null;
2608
- return (0, react_jsx_runtime.jsx)("div", {
2506
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2609
2507
  ref: stackRef,
2610
2508
  className: cx(A.col, styles.cardStack),
2611
- children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2509
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2612
2510
  count: items.length,
2613
2511
  items: items.map((item) => ({
2614
2512
  id: item.id,
@@ -2626,9 +2524,138 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2626
2524
  /** Alias kept for overlay registration. */
2627
2525
  const QuoteCard = AnnotationPanel;
2628
2526
  //#endregion
2527
+ //#region src/client/dom/floating-layer.ts
2528
+ /**
2529
+ * Mount point for the selection UI: a zero-size host inside the transcript's
2530
+ * real scroll container.
2531
+ *
2532
+ * The host is `position: absolute` in **content coordinates**, so floating UI
2533
+ * scrolls with the selected text and the scroller clips it at its own edges.
2534
+ * That is what keeps the toolbar out of the app header and the composer:
2535
+ * clipping happens in the scroller's stacking context instead of competing
2536
+ * with the header (which is not positioned and therefore loses to any
2537
+ * body-level positioned portal, whatever its z-index).
2538
+ *
2539
+ * Two host layouts exist in the product:
2540
+ * - desktop / overlay composer: `ST7X_W_scrollBody` scrolls; the chat frame is
2541
+ * content-sized and the chat's own `.icaHSq_scroll` is `overflow: visible`;
2542
+ * - regular layout: `.icaHSq_scroll` is the scroller.
2543
+ * Walking up from the selected flow item finds whichever one is live.
2544
+ */
2545
+ const HOST_ATTR = "data-dsq-layer";
2546
+ /** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
2547
+ const HOST_Z = 6;
2548
+ const layers = /* @__PURE__ */ new WeakMap();
2549
+ /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2550
+ const itemLayers = /* @__PURE__ */ new WeakMap();
2551
+ let fallback = null;
2552
+ /**
2553
+ * Top edge of the composer seat, in viewport coordinates.
2554
+ *
2555
+ * In the desktop (overlay) layout the composer is `position: sticky` over the
2556
+ * scroller and stacks above this layer (z 7 vs 6), so the scroller's own box
2557
+ * extends **behind** it. Placement that stops at the scroller box therefore
2558
+ * parks popovers under the input box — the bottom edge must be the composer.
2559
+ * In the regular layout the seat sits below the scroller, so it changes nothing.
2560
+ */
2561
+ function composerTop() {
2562
+ if (typeof document === "undefined") return null;
2563
+ const seat = document.querySelector("[data-composer-seat]") ?? document.querySelector("[data-composer-card]");
2564
+ if (seat === null) return null;
2565
+ return seat.getBoundingClientRect().top;
2566
+ }
2567
+ function contentCoords(scroller, left, top) {
2568
+ const rect = scroller.getBoundingClientRect();
2569
+ return {
2570
+ left: left - rect.left - scroller.clientLeft + scroller.scrollLeft,
2571
+ top: top - rect.top - scroller.clientTop + scroller.scrollTop
2572
+ };
2573
+ }
2574
+ function layerFor(scroller) {
2575
+ const cached = layers.get(scroller);
2576
+ if (cached?.host.isConnected) return cached;
2577
+ if (getComputedStyle(scroller).position === "static") scroller.style.position = "relative";
2578
+ let host = scroller.querySelector(`:scope > [${HOST_ATTR}]`);
2579
+ if (!host) {
2580
+ host = document.createElement("div");
2581
+ host.setAttribute(HOST_ATTR, "");
2582
+ host.style.cssText = `position:absolute;left:0;top:0;width:0;height:0;z-index:${HOST_Z};pointer-events:none;`;
2583
+ scroller.appendChild(host);
2584
+ }
2585
+ const layer = {
2586
+ host,
2587
+ scroller,
2588
+ bounds: () => {
2589
+ const rect = scroller.getBoundingClientRect();
2590
+ const top = rect.top + scroller.clientTop;
2591
+ const rawBottom = top + scroller.clientHeight;
2592
+ const cover = composerTop();
2593
+ const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
2594
+ return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
2595
+ },
2596
+ toContent: (left, top) => contentCoords(scroller, left, top),
2597
+ fromContent: (left, top) => {
2598
+ const rect = scroller.getBoundingClientRect();
2599
+ return {
2600
+ left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
2601
+ top: top + rect.top + scroller.clientTop - scroller.scrollTop
2602
+ };
2603
+ }
2604
+ };
2605
+ layers.set(scroller, layer);
2606
+ return layer;
2607
+ }
2608
+ /** Fallback for hosts whose transcript has no scrollable ancestor. */
2609
+ function fallbackLayer() {
2610
+ if (fallback?.host.isConnected) return fallback;
2611
+ const host = document.createElement("div");
2612
+ host.setAttribute(HOST_ATTR, "");
2613
+ host.style.cssText = `position:absolute;left:0;top:0;width:0;height:0;z-index:${HOST_Z};pointer-events:none;`;
2614
+ document.body.appendChild(host);
2615
+ fallback = {
2616
+ host,
2617
+ scroller: document.body,
2618
+ bounds: () => {
2619
+ const bottom = composerTop() ?? window.innerHeight;
2620
+ return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
2621
+ },
2622
+ toContent: (left, top) => ({
2623
+ left,
2624
+ top
2625
+ }),
2626
+ fromContent: (left, top) => ({
2627
+ left,
2628
+ top
2629
+ })
2630
+ };
2631
+ return fallback;
2632
+ }
2633
+ /** Layer for a node inside the transcript (falls back to a body host). */
2634
+ function floatingLayerFor(node) {
2635
+ const item = flowItemOf(node);
2636
+ const cached = item ? itemLayers.get(item) : void 0;
2637
+ if (cached?.host.isConnected) return cached;
2638
+ let el = item;
2639
+ while (el && el !== document.body) {
2640
+ const { overflowY } = getComputedStyle(el);
2641
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
2642
+ const layer = layerFor(el);
2643
+ if (item) itemLayers.set(item, layer);
2644
+ return layer;
2645
+ }
2646
+ el = el.parentElement;
2647
+ }
2648
+ const layer = fallbackLayer();
2649
+ if (item) itemLayers.set(item, layer);
2650
+ return layer;
2651
+ }
2652
+ //#endregion
2629
2653
  //#region src/client/dom/selection.ts
2630
2654
  /** Selection snapshot used by the floating toolbar. */
2631
2655
  const TOOLBAR_OFFSET = 12;
2656
+ const BELOW_GAP = 8;
2657
+ /** Estimated toolbar height; only used to decide above vs below at content top. */
2658
+ const TOOLBAR_HEIGHT = 40;
2632
2659
  const VIEWPORT_MARGIN = 12;
2633
2660
  const LINE_TOP_TOLERANCE = 3;
2634
2661
  /**
@@ -2669,12 +2696,24 @@ function getRangeFirstLineRect(range) {
2669
2696
  const firstTop = Math.min(...rects.map((rect) => rect.top));
2670
2697
  return mergeRects(rects.filter((rect) => Math.abs(rect.top - firstTop) <= LINE_TOP_TOLERANCE));
2671
2698
  }
2672
- function toolbarPosition(rect) {
2699
+ /** Keep the centered toolbar inside the visible horizontal band. */
2700
+ function anchorX(rect, bounds) {
2673
2701
  const center = rect.left + rect.width / 2;
2674
- return {
2675
- left: Math.min(Math.max(center, VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN),
2676
- top: Math.max(rect.top - TOOLBAR_OFFSET, VIEWPORT_MARGIN)
2677
- };
2702
+ const min = bounds.left + VIEWPORT_MARGIN;
2703
+ const max = Math.max(min, bounds.right - VIEWPORT_MARGIN);
2704
+ return Math.min(Math.max(center, min), max);
2705
+ }
2706
+ /**
2707
+ * True when the selection's first line still touches the viewport.
2708
+ *
2709
+ * The toolbar anchors to that line, so once it has scrolled fully out of
2710
+ * view there is nothing to anchor to and the snapshot reads as "gone" — the
2711
+ * toolbar hides and comes back when the user scrolls to the selection again
2712
+ * (scroll re-reads the selection). A partially visible line still counts:
2713
+ * `toolbarPosition` parks the toolbar at the viewport edge in that case.
2714
+ */
2715
+ function isFirstLineOnScreen(rect) {
2716
+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
2678
2717
  }
2679
2718
  /**
2680
2719
  * Read the live selection when it is a non-empty range inside a conversation
@@ -2692,13 +2731,24 @@ function readSelectionSnapshot(toolbarRoot) {
2692
2731
  if (!text) return null;
2693
2732
  const rect = getRangeFirstLineRect(selection.getRangeAt(0));
2694
2733
  if (!rect) return null;
2695
- const { left, top } = toolbarPosition(rect);
2696
- const source = rangeOffsetsInFlowItem(selection.getRangeAt(0));
2734
+ if (!isFirstLineOnScreen(rect)) return null;
2735
+ const layer = floatingLayerFor(anchorNode);
2736
+ const x = anchorX(rect, layer.bounds());
2737
+ const above = layer.toContent(x, rect.top - TOOLBAR_OFFSET);
2738
+ const below = above.top < TOOLBAR_HEIGHT;
2739
+ const anchor = below ? layer.toContent(x, rect.bottom + BELOW_GAP) : above;
2740
+ const range = selection.getRangeAt(0);
2741
+ const union = range.getBoundingClientRect();
2742
+ const selectionBottom = layer.toContent(0, union.height > 0 ? union.bottom : rect.bottom).top;
2743
+ const source = rangeOffsetsInFlowItem(range);
2697
2744
  const anchorHref = (anchorNode instanceof Element ? anchorNode : anchorNode.parentElement)?.closest("a[href]")?.getAttribute("href") ?? void 0;
2698
2745
  return {
2699
2746
  text,
2700
- left,
2701
- top,
2747
+ left: anchor.left,
2748
+ top: anchor.top,
2749
+ below,
2750
+ selectionBottom,
2751
+ layer,
2702
2752
  ...source ? { source } : {},
2703
2753
  ...anchorHref ? { anchorHref } : {}
2704
2754
  };
@@ -2775,35 +2825,72 @@ function SelectionToolbar({ sessionId }) {
2775
2825
  const [status, setStatus] = (0, react.useState)(null);
2776
2826
  const activeRef = (0, react.useRef)(null);
2777
2827
  activeRef.current = active;
2828
+ /** Annotation saved when the comment step opened; the box only edits it. */
2829
+ const pendingIdRef = (0, react.useRef)(null);
2778
2830
  (0, react.useEffect)(() => {
2779
2831
  ensureToolbarStyles();
2780
2832
  if (sessionId) bindAnnotationSession(sessionId);
2781
2833
  }, [sessionId]);
2782
2834
  const hide = (0, react.useCallback)(() => {
2835
+ pendingIdRef.current = null;
2783
2836
  setActive(null);
2784
2837
  setPhase("idle");
2785
2838
  setComment("");
2786
2839
  setStatus(null);
2787
2840
  }, []);
2841
+ /**
2842
+ * Keep what was typed when the comment popover goes away without an explicit
2843
+ * cancel. The annotation itself was saved when the step opened, so dropping
2844
+ * the text would also lose the only optional part of the interaction.
2845
+ */
2846
+ const flushComment = (0, react.useCallback)(() => {
2847
+ const id = pendingIdRef.current;
2848
+ pendingIdRef.current = null;
2849
+ if (!id) return;
2850
+ const typed = commentRef.current?.value ?? "";
2851
+ if (typed.trim()) updateAnnotationComment(void 0, id, typed);
2852
+ }, []);
2853
+ /** Save the current selection without a comment; returns the store id. */
2854
+ const saveSelection = (0, react.useCallback)(() => {
2855
+ const snapshot = activeRef.current;
2856
+ if (!snapshot) return null;
2857
+ return saveAnnotation(void 0, {
2858
+ text: snapshot.text,
2859
+ sources: snapshot.source ? [{
2860
+ text: snapshot.text,
2861
+ messageId: snapshot.source.messageId,
2862
+ messageKind: snapshot.source.messageKind,
2863
+ startOffset: snapshot.source.startOffset,
2864
+ endOffset: snapshot.source.endOffset
2865
+ }] : void 0
2866
+ }).id;
2867
+ }, []);
2788
2868
  const refresh = (0, react.useCallback)(() => {
2789
2869
  const next = readSelectionSnapshot(toolbarRef.current);
2790
2870
  if (!next) {
2791
2871
  if (phaseRef.current === "idle") setActive(null);
2792
2872
  return;
2793
2873
  }
2794
- setActive((prev) => {
2795
- if (phaseRef.current === "comment" && prev && prev.text !== next.text) {
2796
- setPhase("idle");
2797
- setComment("");
2798
- }
2799
- if (prev && prev.text === next.text && prev.left === next.left && prev.top === next.top) return prev;
2800
- return next;
2801
- });
2802
- }, []);
2874
+ const current = activeRef.current;
2875
+ if (phaseRef.current === "comment" && current && current.text !== next.text) {
2876
+ flushComment();
2877
+ setPhase("idle");
2878
+ setComment("");
2879
+ }
2880
+ setActive((prev) => prev && prev.text === next.text && prev.left === next.left && prev.top === next.top && prev.below === next.below && prev.layer === next.layer ? prev : next);
2881
+ }, [flushComment]);
2803
2882
  const phaseRef = (0, react.useRef)(phase);
2804
2883
  phaseRef.current = phase;
2805
2884
  (0, react.useEffect)(() => {
2806
- const schedule = () => window.setTimeout(refresh, 0);
2885
+ let scheduled = false;
2886
+ const schedule = () => {
2887
+ if (scheduled) return;
2888
+ scheduled = true;
2889
+ window.setTimeout(() => {
2890
+ scheduled = false;
2891
+ refresh();
2892
+ }, 0);
2893
+ };
2807
2894
  let dragging = false;
2808
2895
  const onPointerDown = (event) => {
2809
2896
  const target = event.target;
@@ -2813,6 +2900,7 @@ function SelectionToolbar({ sessionId }) {
2813
2900
  if (inToolbar || inComment) return;
2814
2901
  dragging = true;
2815
2902
  if (phaseRef.current === "comment") {
2903
+ flushComment();
2816
2904
  hide();
2817
2905
  return;
2818
2906
  }
@@ -2834,7 +2922,7 @@ function SelectionToolbar({ sessionId }) {
2834
2922
  const onScroll = (event) => {
2835
2923
  const target = event.target;
2836
2924
  if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
2837
- hide();
2925
+ schedule();
2838
2926
  };
2839
2927
  document.addEventListener("pointerdown", onPointerDown, true);
2840
2928
  document.addEventListener("pointerup", onPointerUp, true);
@@ -2850,12 +2938,27 @@ function SelectionToolbar({ sessionId }) {
2850
2938
  window.removeEventListener("resize", schedule);
2851
2939
  document.removeEventListener("scroll", onScroll, true);
2852
2940
  };
2853
- }, [hide, refresh]);
2941
+ }, [
2942
+ flushComment,
2943
+ hide,
2944
+ refresh
2945
+ ]);
2854
2946
  const placeCommentPop = (0, react.useCallback)(() => {
2947
+ const snapshot = activeRef.current;
2948
+ const layer = snapshot?.layer;
2855
2949
  const toolbar = toolbarRef.current;
2856
2950
  const pop = commentWrapRef.current;
2857
- if (!toolbar || !pop) return;
2858
- const anchor = toolbar.getBoundingClientRect();
2951
+ if (!snapshot || !layer || !toolbar || !pop) return;
2952
+ const bounds = layer.bounds();
2953
+ const toolbarW = toolbar.offsetWidth;
2954
+ const toolbarH = toolbar.offsetHeight;
2955
+ const home = layer.fromContent(snapshot.left, snapshot.top);
2956
+ const anchor = {
2957
+ left: home.left - toolbarW / 2,
2958
+ top: home.top - (snapshot.below ? 0 : toolbarH),
2959
+ width: toolbarW,
2960
+ height: toolbarH
2961
+ };
2859
2962
  pop.style.display = "flex";
2860
2963
  pop.style.visibility = "hidden";
2861
2964
  const panel = {
@@ -2863,24 +2966,45 @@ function SelectionToolbar({ sessionId }) {
2863
2966
  height: pop.offsetHeight || 180
2864
2967
  };
2865
2968
  pop.style.visibility = "";
2866
- const { top } = placePopover(anchor, panel, "bottom");
2969
+ const gap = 8;
2970
+ const margin = 8;
2971
+ const belowTop = layer.fromContent(0, snapshot.selectionBottom).top + gap;
2972
+ const aboveTop = anchor.top - panel.height - gap;
2973
+ const top = belowTop <= bounds.bottom - margin - panel.height ? belowTop : aboveTop >= bounds.top + margin ? aboveTop : Math.max(bounds.top + margin, Math.min(belowTop, bounds.bottom - margin - panel.height));
2867
2974
  const half = panel.width / 2 + 8;
2868
- const center = Math.min(Math.max(anchor.left + anchor.width / 2, half), Math.max(half, window.innerWidth - half));
2869
- pop.style.top = `${top}px`;
2870
- pop.style.left = `${center}px`;
2975
+ const min = bounds.left + half;
2976
+ const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
2977
+ const at = layer.toContent(center, top);
2978
+ pop.style.top = `${at.top}px`;
2979
+ pop.style.left = `${at.left}px`;
2980
+ const sideGap = 6;
2981
+ const popLeftView = layer.fromContent(at.left, at.top).left - panel.width / 2;
2982
+ const popTopView = top;
2983
+ let leftView = popLeftView;
2984
+ let topView = popTopView - sideGap - toolbarH;
2985
+ if (topView < bounds.top + margin) topView = popTopView + panel.height + sideGap;
2986
+ leftView = Math.max(bounds.left + margin, Math.min(leftView, bounds.right - margin - toolbarW));
2987
+ topView = Math.max(bounds.top + margin, Math.min(topView, bounds.bottom - margin - toolbarH));
2988
+ const toolbarAt = layer.toContent(leftView, topView);
2989
+ toolbar.style.left = `${toolbarAt.left}px`;
2990
+ toolbar.style.top = `${toolbarAt.top}px`;
2871
2991
  }, []);
2992
+ (0, react.useLayoutEffect)(() => {
2993
+ if (phase === "comment") placeCommentPop();
2994
+ }, [
2995
+ active,
2996
+ phase,
2997
+ placeCommentPop
2998
+ ]);
2872
2999
  (0, react.useEffect)(() => {
2873
- if (phase === "comment") {
2874
- placeCommentPop();
2875
- commentRef.current?.focus();
2876
- }
2877
- }, [phase, placeCommentPop]);
3000
+ if (phase === "comment") commentRef.current?.focus();
3001
+ }, [phase]);
2878
3002
  (0, react.useEffect)(() => {
2879
3003
  const onResize = () => placeCommentPop();
2880
3004
  window.addEventListener("resize", onResize);
2881
3005
  return () => window.removeEventListener("resize", onResize);
2882
3006
  }, [placeCommentPop]);
2883
- (0, react.useEffect)(() => {
3007
+ (0, react.useLayoutEffect)(() => {
2884
3008
  if (phase !== "comment") return;
2885
3009
  const area = commentRef.current;
2886
3010
  if (!area) return;
@@ -2907,12 +3031,18 @@ function SelectionToolbar({ sessionId }) {
2907
3031
  setStatus(t("toolbar.copyFailed"));
2908
3032
  }
2909
3033
  }, [hide]);
3034
+ /**
3035
+ * "Add to task" is the commit: the quote is saved immediately, with or
3036
+ * without a comment. The popover only edits that saved entry, so dismissing
3037
+ * it (outside click, new selection) can never undo the annotation.
3038
+ */
2910
3039
  const openComment = (0, react.useCallback)(() => {
2911
3040
  if (!activeRef.current) return;
3041
+ pendingIdRef.current = saveSelection();
2912
3042
  setPhase("comment");
2913
3043
  setComment("");
2914
3044
  setStatus(null);
2915
- }, []);
3045
+ }, [saveSelection]);
2916
3046
  /**
2917
3047
  * Hand the selected link to the system browser.
2918
3048
  *
@@ -2930,89 +3060,75 @@ function SelectionToolbar({ sessionId }) {
2930
3060
  clearNativeSelection();
2931
3061
  hide();
2932
3062
  }, [hide]);
2933
- /** Quick-add without opening the comment step (double-tap / “Skip comment”). */
2934
- const commitAnnotation = (0, react.useCallback)((body) => {
2935
- const snapshot = activeRef.current;
2936
- if (!snapshot) {
2937
- hide();
2938
- return;
2939
- }
2940
- saveAnnotation(void 0, {
2941
- text: snapshot.text,
2942
- ...body && body.trim() ? { comment: body.trim() } : {},
2943
- sources: snapshot.source ? [{
2944
- text: snapshot.text,
2945
- messageId: snapshot.source.messageId,
2946
- messageKind: snapshot.source.messageKind,
2947
- startOffset: snapshot.source.startOffset,
2948
- endOffset: snapshot.source.endOffset
2949
- }] : void 0
2950
- });
2951
- getAnnotations().length;
3063
+ /** Close the optional comment step; the annotation stays saved either way. */
3064
+ const finishComment = (0, react.useCallback)((keepComment) => {
3065
+ if (keepComment) flushComment();
3066
+ else pendingIdRef.current = null;
2952
3067
  clearNativeSelection();
2953
3068
  hide();
2954
- setStatus(null);
2955
3069
  window.setTimeout(focusComposer, 16);
2956
- }, [hide]);
3070
+ }, [flushComment, hide]);
2957
3071
  if (!active) return null;
2958
3072
  const toolbarStyle = {
2959
3073
  left: active.left,
2960
3074
  top: active.top
2961
3075
  };
2962
3076
  const link = linkTarget(active.text, active.anchorHref);
2963
- return (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
2964
- (0, react_jsx_runtime.jsx)("div", {
3077
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2965
3079
  ref: toolbarRef,
2966
3080
  className: cx(A.row, styles.toolbar),
2967
- style: toolbarStyle,
3081
+ style: phase === "comment" ? void 0 : toolbarStyle,
2968
3082
  role: "toolbar",
2969
3083
  "aria-label": t("toolbar.aria"),
2970
3084
  "data-dsq-toolbar": "true",
3085
+ "data-anchor": phase === "comment" ? "popover-top" : active.below ? "below" : "above",
2971
3086
  onPointerDown: handleToolbarPointerDown,
2972
- children: phase === "comment" ? (0, react_jsx_runtime.jsx)("div", {
3087
+ children: phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2973
3088
  className: cx(A.inlineRow, styles.toolbarInner),
2974
- children: (0, react_jsx_runtime.jsx)("span", {
3089
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2975
3090
  style: {
2976
3091
  padding: "0 8px",
2977
3092
  fontSize: 13,
2978
- opacity: .8
3093
+ opacity: .8,
3094
+ whiteSpace: "nowrap"
2979
3095
  },
2980
3096
  children: t("toolbar.commentHint")
2981
3097
  })
2982
- }) : (0, react_jsx_runtime.jsxs)("div", {
3098
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2983
3099
  className: cx(A.inlineRow, styles.toolbarInner),
2984
3100
  children: [
2985
- link ? (0, react_jsx_runtime.jsx)("button", {
3101
+ link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2986
3102
  type: "button",
2987
3103
  className: cx(A.plain, A.inlineRow, styles.button),
2988
3104
  onClick: handleOpenLink,
2989
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3105
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
2990
3106
  name: "external-link",
2991
3107
  size: 14
2992
3108
  }), t("toolbar.openLink")]
2993
3109
  }) : null,
2994
- link ? (0, react_jsx_runtime.jsx)("span", {
3110
+ link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2995
3111
  className: styles.divider,
2996
- "aria-hidden": true
3112
+ "aria-hidden": "true"
2997
3113
  }) : null,
2998
- (0, react_jsx_runtime.jsx)("button", {
3114
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2999
3115
  type: "button",
3000
3116
  className: cx(A.plain, A.inlineRow, styles.button),
3001
3117
  onClick: () => void handleCopy(),
3002
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3118
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3003
3119
  name: "copy",
3004
3120
  size: 14
3005
3121
  }), t("toolbar.copy")]
3006
3122
  }),
3007
- (0, react_jsx_runtime.jsx)("span", {
3123
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3008
3124
  className: styles.divider,
3009
- "aria-hidden": true
3125
+ "aria-hidden": "true"
3010
3126
  }),
3011
- (0, react_jsx_runtime.jsx)("button", {
3127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3012
3128
  type: "button",
3013
3129
  className: cx(A.plain, A.inlineRow, styles.button),
3014
3130
  onClick: openComment,
3015
- children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
3131
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3016
3132
  name: "plus",
3017
3133
  size: 14
3018
3134
  }), t("toolbar.addToTask")]
@@ -3020,14 +3136,13 @@ function SelectionToolbar({ sessionId }) {
3020
3136
  ]
3021
3137
  })
3022
3138
  }),
3023
- phase === "comment" ? (0, react_jsx_runtime.jsxs)("div", {
3139
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3024
3140
  ref: commentWrapRef,
3025
3141
  className: cx(A.col, styles.commentPop),
3026
- style: void 0,
3027
3142
  role: "dialog",
3028
3143
  "aria-label": t("toolbar.commentDialogAria"),
3029
3144
  onPointerDown: handleToolbarPointerDown,
3030
- children: [(0, react_jsx_runtime.jsx)("textarea", {
3145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3031
3146
  ref: commentRef,
3032
3147
  className: cx(A.box, styles.commentInput),
3033
3148
  placeholder: t("common.commentPlaceholder"),
@@ -3036,37 +3151,38 @@ function SelectionToolbar({ sessionId }) {
3036
3151
  onKeyDown: (e) => {
3037
3152
  if (e.key === "Escape") {
3038
3153
  e.preventDefault();
3154
+ pendingIdRef.current = null;
3039
3155
  hide();
3040
3156
  return;
3041
3157
  }
3042
3158
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3043
3159
  e.preventDefault();
3044
- commitAnnotation(comment);
3160
+ finishComment(true);
3045
3161
  }
3046
3162
  }
3047
- }), (0, react_jsx_runtime.jsxs)("div", {
3163
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3048
3164
  className: styles.commentActions,
3049
- children: [(0, react_jsx_runtime.jsx)("button", {
3165
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3050
3166
  type: "button",
3051
3167
  className: cx(A.plain, A.inlineRow, styles.button),
3052
- onClick: () => commitAnnotation(void 0),
3168
+ onClick: () => finishComment(false),
3053
3169
  children: t("toolbar.skipComment")
3054
- }), (0, react_jsx_runtime.jsx)("button", {
3170
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3055
3171
  type: "button",
3056
3172
  className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3057
3173
  disabled: !comment.trim(),
3058
- onClick: () => commitAnnotation(comment),
3174
+ onClick: () => finishComment(true),
3059
3175
  children: t("toolbar.addComment")
3060
3176
  })]
3061
3177
  })]
3062
3178
  }) : null,
3063
- status ? (0, react_jsx_runtime.jsx)("div", {
3179
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3064
3180
  className: styles.status,
3065
3181
  style: toolbarStyle,
3066
3182
  role: "status",
3067
3183
  children: status
3068
3184
  }) : null
3069
- ] }), document.body);
3185
+ ] }), active.layer.host);
3070
3186
  }
3071
3187
  //#endregion
3072
3188
  //#region src/client/dom/decorateAnnotationDirectives.ts
@@ -3161,16 +3277,82 @@ function watchAnnotationDirectives(root) {
3161
3277
  return () => observer.disconnect();
3162
3278
  }
3163
3279
  //#endregion
3280
+ //#region src/client/dom/stripSubmissionEcho.ts
3281
+ /**
3282
+ * Strip the annotation protocol from the host's local submission echo.
3283
+ *
3284
+ * On submit the host paints an optimistic bubble (`[data-submission-echo]`)
3285
+ * from the composer draft — which, at that instant, is the protocol block the
3286
+ * send gesture just folded in. That echo is rendered by the chat flow itself,
3287
+ * not by a `conversation.chat.node` registration, so `UserMessageDisplay`
3288
+ * never sees it; with a slow model the raw JSON sits on screen until the
3289
+ * durable `user/message` event arrives.
3290
+ *
3291
+ * The durable message keeps the block (the model needs it). This is display
3292
+ * only: the echo's plain text run is rewritten to the user's own text. Any
3293
+ * other DOM shape (text split by session chips, a truncated block) is left
3294
+ * untouched rather than rebuilt, so nothing but our own protocol is edited.
3295
+ */
3296
+ const ECHO_SELECTOR = "[data-submission-echo]";
3297
+ const STRIPPED_ATTR = "data-dsq-echo-stripped";
3298
+ /** The deepest run that carries both markers, or null. */
3299
+ function protocolRun(row) {
3300
+ for (const el of row.querySelectorAll("*")) {
3301
+ if (el.children.length > 0) continue;
3302
+ const text = el.textContent ?? "";
3303
+ if (text.includes("<response-annotations>") && text.includes("</response-annotations>")) return el;
3304
+ }
3305
+ return null;
3306
+ }
3307
+ function strip(row) {
3308
+ if (row.hasAttribute(STRIPPED_ATTR)) return;
3309
+ const run = protocolRun(row);
3310
+ if (run === null) return;
3311
+ const scan = scanAnnotatedMessage(run.textContent ?? "");
3312
+ if (scan.annotations.length === 0) return;
3313
+ run.textContent = scan.rest;
3314
+ row.setAttribute(STRIPPED_ATTR, "");
3315
+ }
3316
+ /**
3317
+ * Watch for local submission echoes and rewrite the protocol block out of
3318
+ * them. Runs once immediately, then on every DOM insertion.
3319
+ */
3320
+ function watchSubmissionEcho(root) {
3321
+ const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
3322
+ if (!scope) return () => {};
3323
+ const sweep = () => {
3324
+ for (const row of scope.querySelectorAll(ECHO_SELECTOR)) strip(row);
3325
+ };
3326
+ sweep();
3327
+ if (typeof MutationObserver === "undefined") return () => {};
3328
+ let frame = 0;
3329
+ const observer = new MutationObserver(() => {
3330
+ if (frame !== 0) return;
3331
+ frame = window.requestAnimationFrame(() => {
3332
+ frame = 0;
3333
+ sweep();
3334
+ });
3335
+ });
3336
+ observer.observe(scope, {
3337
+ childList: true,
3338
+ subtree: true
3339
+ });
3340
+ return () => {
3341
+ observer.disconnect();
3342
+ if (frame !== 0) window.cancelAnimationFrame(frame);
3343
+ };
3344
+ }
3345
+ //#endregion
3164
3346
  //#region src/client/transcript/TranscriptQuoteCard.tsx
3165
3347
  /**
3166
- * History-side cards for one user message's annotations.
3348
+ * History-side summary for one user message's annotations.
3167
3349
  *
3168
- * One card per annotation, each showing the selected text **and** the user's
3169
- * comment. The comment is how the user recognizes their own annotation, so it
3170
- * belongs on the visible card — not behind a hover panel that has to be
3171
- * discovered. Clicking a card scrolls back to the marked text in the source
3172
- * message; the ordinal badge matches the `:dsh-annotation{index="N"}` the model
3173
- * refers to.
3350
+ * One block per message, not one card per annotation: the block carries the
3351
+ * count and the hover list shows every annotation with its selected text and
3352
+ * comment. That is the same shape the composer uses for pending selections, so
3353
+ * a message reads the same before and after it is sent. Clicking an item
3354
+ * scrolls back to the marked text in the source message; the ordinal badge
3355
+ * matches the `:dsh-annotation{index="N"}` the model refers to.
3174
3356
  */
3175
3357
  function TranscriptQuoteCard({ node }) {
3176
3358
  ensureToolbarStyles();
@@ -3192,94 +3374,126 @@ function TranscriptQuoteCard({ node }) {
3192
3374
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
3193
3375
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
3194
3376
  }, []);
3195
- const onSelect = (0, react.useCallback)((index, text) => {
3196
- scrollToAnnotation(index + 1, text, markIdOf(index));
3197
- }, [markIdOf]);
3377
+ const onSelect = (0, react.useCallback)((index, _item) => {
3378
+ const quote = quotes[index];
3379
+ if (quote === void 0) return;
3380
+ scrollToAnnotation(index + 1, quote.text, markIdOf(index));
3381
+ }, [markIdOf, quotes]);
3198
3382
  if (quotes.length === 0) return null;
3199
- return (0, react_jsx_runtime.jsx)("div", {
3383
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3200
3384
  ref: stackRef,
3201
- className: styles.tCardStack,
3385
+ className: styles.tSummary,
3202
3386
  "data-dsq-deco": "true",
3203
- children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
3204
- className: cx(A.plain, styles.tCardButton),
3205
- role: "button",
3206
- tabIndex: 0,
3207
- "aria-label": quote.title,
3208
- onClick: () => onSelect(index, quote.text),
3209
- onKeyDown: (event) => {
3210
- if (event.key !== "Enter" && event.key !== " ") return;
3211
- event.preventDefault();
3212
- onSelect(index, quote.text);
3213
- },
3214
- children: (0, react_jsx_runtime.jsx)(AnnotationCardView, {
3215
- variant: "transcript",
3387
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
3388
+ count: quotes.length,
3389
+ items: quotes.map((quote, index) => ({
3390
+ id: `dqsm-${index}`,
3216
3391
  title: quote.title,
3217
3392
  text: quote.text,
3218
- comment: quote.comment || void 0,
3219
- index: index + 1
3220
- })
3221
- }, `ann-${index}`))
3393
+ ...quote.comment ? { comment: quote.comment } : {}
3394
+ })),
3395
+ onItemSelect: onSelect
3396
+ })
3222
3397
  });
3223
3398
  }
3224
3399
  //#endregion
3225
3400
  //#region src/client/transcript/UserMessageDisplay.tsx
3226
- function contentImages(content) {
3227
- if (!Array.isArray(content)) return [];
3401
+ function isTextBlock(block) {
3402
+ if (block === null || typeof block !== "object") return false;
3403
+ const candidate = block;
3404
+ if ((candidate.type === "image" || candidate.type === "file") && candidate.attachment !== void 0) return false;
3405
+ return candidate.type === "text" || typeof candidate.text === "string";
3406
+ }
3407
+ /**
3408
+ * Replace all text blocks with one stripped text block, keeping attachments and
3409
+ * unknown blocks in place. The host's `contentParts` joins text blocks and
3410
+ * lifts attachments out anyway, so one text block is the same message content
3411
+ * with the protocol block gone.
3412
+ */
3413
+ function strippedContent(raw, text) {
3414
+ const rest = Array.isArray(raw) ? raw.filter((block) => !isTextBlock(block)) : [];
3415
+ if (text) rest.unshift({
3416
+ type: "text",
3417
+ text
3418
+ });
3419
+ return rest;
3420
+ }
3421
+ /** Image attachments for the factory-less fallback bubble. */
3422
+ function imageAttachments(raw) {
3423
+ if (!Array.isArray(raw)) return [];
3228
3424
  const images = [];
3229
- for (const block of content) {
3425
+ for (const block of raw) {
3230
3426
  if (block === null || typeof block !== "object") continue;
3231
3427
  const candidate = block;
3232
- if (candidate.type !== "image" || candidate.attachment === void 0) continue;
3233
- images.push({ attachment: candidate.attachment });
3428
+ if (candidate.type === "image" && candidate.attachment !== void 0) images.push(candidate.attachment);
3234
3429
  }
3235
3430
  return images;
3236
3431
  }
3237
3432
  /**
3238
3433
  * Replacement for the built-in `user` Chat node view.
3239
3434
  *
3240
- * Strips the `<response-annotations>` protocol block from the bubble **and**
3241
- * renders one card per annotation right above it — the same order the composer
3242
- * shows them in, so the sent message reads like the draft did.
3435
+ * It does **not** redraw the bubble or its actions row. It resolves the host's
3436
+ * own `UserMessageNodeView` from `ctx.slots.entries('conversation.chat.node')`
3437
+ * and renders that component with a rewritten `node`: the annotation protocol
3438
+ * block is removed from the text blocks, everything else (attachments, file
3439
+ * cards, reference labels, time, copy, and any plugin that portals into the
3440
+ * actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
3441
+ * The annotation cards are the only extra DOM, prepended above the host row.
3243
3442
  *
3244
- * Why the cards live here instead of on a `select-quote` Chat node:
3245
- * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
3246
- * allowlist (system-prompt, user, steering, turn-trigger, turn-process,
3247
- * turn-error, turn-max-tokens, turn-tail). A node whose kind is not on that list
3248
- * is classified as a turn-process member and `hidden` inside the collapsed
3249
- * process disclosure of a completed turn — mounted, then invisible. `user` is on
3250
- * the list, so rendering the cards inside this component is the only placement
3251
- * that survives turn folding.
3443
+ * Cards must live inside the `user` node view rather than on a
3444
+ * `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
3445
+ * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3446
+ * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
3447
+ * other kind is `hidden` when the completed turn's process disclosure folds.
3252
3448
  */
3253
- function UserMessageDisplay({ node, renderMessageImages }) {
3449
+ function UserMessageDisplay(props) {
3254
3450
  ensureToolbarStyles();
3451
+ const { node, hostUserOf, ...rest } = props;
3255
3452
  const raw = contentBlocksToText(node.data?.content);
3256
3453
  const scanned = scanAnnotatedMessage(raw);
3257
3454
  const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
3258
3455
  const quotes = scanned.annotations;
3259
- const images = contentImages(node.data?.content);
3260
- if (!text && images.length === 0 && quotes.length === 0) return null;
3261
- return (0, react_jsx_runtime.jsx)("div", {
3456
+ const rewritten = quotes.length > 0 || text !== raw.trim();
3457
+ const displayNode = (0, react.useMemo)(() => rewritten ? {
3458
+ ...node,
3459
+ data: {
3460
+ ...node.data,
3461
+ content: strippedContent(node.data?.content, text)
3462
+ }
3463
+ } : node, [
3464
+ node,
3465
+ rewritten,
3466
+ text
3467
+ ]);
3468
+ const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
3469
+ const Host = hostUserOf?.();
3470
+ if (Host !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Host, {
3471
+ ...rest,
3472
+ node: displayNode
3473
+ })] });
3474
+ const images = imageAttachments(node.data?.content);
3475
+ const loadImage = props.loadImage;
3476
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3262
3477
  className: cx(A.colEnd, styles.userRow),
3263
- children: (0, react_jsx_runtime.jsxs)("div", {
3478
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3264
3479
  className: cx(A.colEnd, styles.userStack),
3265
- children: [
3266
- images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3267
- className: cx(A.colEnd, styles.userImages),
3268
- "data-message-attachments": true,
3269
- children: renderMessageImages({
3270
- images,
3271
- align: "end",
3272
- compact: images.length > 1
3273
- })
3274
- }, "images") : null,
3275
- quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null,
3276
- text ? (0, react_jsx_runtime.jsx)("div", {
3277
- className: styles.userBubble,
3278
- children: text
3279
- }, "bubble") : null
3280
- ]
3480
+ "data-dsq-fallback-content": "true",
3481
+ children: [images.length > 0 && loadImage !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3482
+ className: cx(A.colEnd, styles.userImages),
3483
+ "data-message-attachments": true,
3484
+ children: images.map((attachment, index) => {
3485
+ const url = loadImage(attachment);
3486
+ return url === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
3487
+ src: url,
3488
+ alt: ""
3489
+ }, `image-${index}`);
3490
+ })
3491
+ }) : null, text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3492
+ className: styles.userBubble,
3493
+ children: text
3494
+ }) : null]
3281
3495
  })
3282
- });
3496
+ })] });
3283
3497
  }
3284
3498
  //#endregion
3285
3499
  //#region src/client/state/annotation-persistence.ts
@@ -3473,6 +3687,7 @@ function apply(ctx) {
3473
3687
  ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
3474
3688
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
3475
3689
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
3690
+ ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
3476
3691
  ctx.effect(() => {
3477
3692
  syncPendingAnnotationJobs(getAnnotations());
3478
3693
  return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
@@ -3504,14 +3719,28 @@ function apply(ctx) {
3504
3719
  order: 1
3505
3720
  }, SessionBridge);
3506
3721
  });
3722
+ const hostUserOf = () => {
3723
+ const candidates = (typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : []).filter((entry) => entry.options?.key === "user" && entry.options?.priority !== -10);
3724
+ return (candidates.find((entry) => componentName(entry.component) === "UserMessageNodeView") ?? candidates[0])?.component;
3725
+ };
3726
+ const UserNode = (props) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
3727
+ ...props,
3728
+ hostUserOf
3729
+ });
3507
3730
  ctx.slots.inject("conversation.chat.node", () => {
3508
3731
  ctx.slots.register({
3509
3732
  name: "conversation.chat.node",
3510
3733
  key: "user",
3511
- priority: -10
3512
- }, UserMessageDisplay);
3734
+ priority: -10,
3735
+ locale: "chat"
3736
+ }, UserNode);
3513
3737
  });
3514
3738
  }
3739
+ /** Component name across memo/forwardRef/plain function shapes. */
3740
+ function componentName(component) {
3741
+ const candidate = component;
3742
+ return candidate?.type?.name ?? candidate?.name ?? candidate?.displayName;
3743
+ }
3515
3744
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3516
3745
  function SessionBridge({ sessionId }) {
3517
3746
  bindAnnotationSession(sessionId);