dsh-select-quote 0.3.7 → 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 (2) hide show
  1. package/lib/client.js +366 -365
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -223,8 +223,8 @@ ${atomRules}
223
223
  内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
224
224
  host 内部次序:status 3 > toolbar 2 > commentPop 1。
225
225
 
226
- 摘要行与批注卡片在内容普通流里,不参与这套层带;悬停面板仍是 body 级 fixed
227
- (原因见 AnnotationCardView 里 contain:layout 的说明)。 */
226
+ 摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
227
+ body 级 fixed(原因见 AnnotationCardView 里 contain:layout 的说明)。 */
228
228
 
229
229
  .dsq_toolbar {
230
230
  position: absolute;
@@ -334,78 +334,8 @@ ${atomRules}
334
334
  pointer-events: auto;
335
335
  }
336
336
 
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. */
337
+ /* Corner remove-all button on the summary block: hidden until the block is
338
+ hovered or focused. */
409
339
  .dsq_cardClose {
410
340
  position: absolute;
411
341
  top: 4px;
@@ -422,8 +352,6 @@ ${atomRules}
422
352
  transition: opacity 0.12s, background-color 0.12s;
423
353
  }
424
354
 
425
- .dsq_card:hover .dsq_cardClose,
426
- .dsq_card:focus-within .dsq_cardClose,
427
355
  .dsq_summary:hover .dsq_cardClose,
428
356
  .dsq_summary:focus-within .dsq_cardClose {
429
357
  opacity: 1;
@@ -435,33 +363,6 @@ ${atomRules}
435
363
  color: var(--dsq-label);
436
364
  }
437
365
 
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
366
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
466
367
  .dsq_summary {
467
368
  position: relative;
@@ -770,98 +671,19 @@ ${atomRules}
770
671
  gap: 6px;
771
672
  }
772
673
 
773
- /* Transcript cards — in-flow rows under the user bubble, side by side. */
774
- .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 {
775
678
  display: flex;
776
- flex-direction: row;
777
- flex-wrap: wrap;
778
679
  justify-content: flex-end;
779
- align-items: stretch;
780
- gap: 6px;
781
680
  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;
788
681
  }
789
682
 
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 {
683
+ .dsq_tSummary .dsq_summary {
684
+ width: auto;
685
+ min-width: 220px;
794
686
  max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
795
- margin-left: auto;
796
- }
797
-
798
- .dsq_tCardIcon {
799
- width: 40px;
800
- height: 40px;
801
- font-size: 14px;
802
- }
803
-
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;
811
- font: inherit;
812
- text-align: left;
813
- border-radius: 14px;
814
- }
815
-
816
- .dsq_tCardButton:focus-visible {
817
- outline: 2px solid var(--dsq-accent);
818
- outline-offset: 2px;
819
- }
820
-
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;
829
- }
830
-
831
- .dsq_tCardButton .dsq_cardIcon {
832
- width: 22px;
833
- height: 22px;
834
- border-radius: 8px;
835
- font-size: 11px;
836
- }
837
-
838
- .dsq_tCardButton .dsq_cardTitle {
839
- display: -webkit-box;
840
- -webkit-line-clamp: 2;
841
- -webkit-box-orient: vertical;
842
- overflow: hidden;
843
- white-space: normal;
844
- overflow-wrap: anywhere;
845
- font-size: 13px;
846
- line-height: 18px;
847
- }
848
-
849
- .dsq_tCardButton .dsq_cardSubtitle {
850
- display: -webkit-box;
851
- -webkit-line-clamp: 3;
852
- -webkit-box-orient: vertical;
853
- overflow: hidden;
854
- white-space: pre-wrap;
855
- overflow-wrap: anywhere;
856
- margin-top: 2px;
857
- font-size: 12px;
858
- line-height: 16px;
859
- }
860
-
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);
865
687
  }
866
688
 
867
689
  /* Replacement user bubble (quote block stripped from display). */
@@ -892,6 +714,12 @@ ${atomRules}
892
714
  max-width: 100%;
893
715
  }
894
716
 
717
+ .dsq_userImages img {
718
+ display: block;
719
+ max-width: 100%;
720
+ border-radius: 12px;
721
+ }
722
+
895
723
  /* Model-written :dsh-annotation{index="N"} chips. */
896
724
  .dsq_annDirective {
897
725
  appearance: none;
@@ -974,13 +802,7 @@ ${atomRules}
974
802
  }
975
803
 
976
804
  /* 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
-
805
+ --dsq-* alias follows it. Only values we own ourselves need restating. */
984
806
  body[data-ds-dark-theme] .dsq_annMark {
985
807
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
986
808
  }
@@ -1009,14 +831,8 @@ const styles = {
1009
831
  divider: "dsq_divider",
1010
832
  status: "dsq_status",
1011
833
  cardStack: "dsq_cardStack",
1012
- card: "dsq_card",
1013
- cardIcon: "dsq_cardIcon",
1014
- cardBody: "dsq_cardBody",
1015
- cardTitle: "dsq_cardTitle",
1016
- cardSubtitle: "dsq_cardSubtitle",
1017
834
  cardClose: "dsq_cardClose",
1018
835
  cardPad: "dsq_cardPad",
1019
- cardAction: "dsq_cardAction",
1020
836
  summary: "dsq_summary",
1021
837
  summaryIcon: "dsq_summaryIcon",
1022
838
  summaryBody: "dsq_summaryBody",
@@ -1048,10 +864,7 @@ const styles = {
1048
864
  commentPop: "dsq_commentPop",
1049
865
  commentInput: "dsq_commentInput",
1050
866
  commentActions: "dsq_commentActions",
1051
- tCardStack: "dsq_tCardStack",
1052
- tCardButton: "dsq_tCardButton",
1053
- tCard: "dsq_tCard",
1054
- tCardIcon: "dsq_tCardIcon",
867
+ tSummary: "dsq_tSummary",
1055
868
  userRow: "dsq_userRow",
1056
869
  userStack: "dsq_userStack",
1057
870
  userImages: "dsq_userImages",
@@ -1591,47 +1404,80 @@ function stripProtocolHeader(prefix) {
1591
1404
  if (prefix.indexOf("# Response annotations:") >= 0) return "";
1592
1405
  return prefix.replace(/\s+$/, "");
1593
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
+ }
1594
1453
  /**
1595
1454
  * Read plugin annotations out of a durable message. Tolerates the JSON block
1596
1455
  * and strips it from `rest` so the bubble can show only the user's question.
1597
1456
  */
1598
1457
  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 {
1458
+ const parts = blockParts(text);
1459
+ if (parts === null) return {
1602
1460
  annotations: [],
1603
1461
  rest: text
1604
1462
  };
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 {
1463
+ const parsed = parseWire(parts);
1464
+ if (parsed === null) return {
1618
1465
  annotations: [],
1619
- rest
1466
+ rest: text.replace(/^\s+/, "")
1620
1467
  };
1468
+ const rest = joinedRest(parts);
1621
1469
  const annotations = [];
1622
1470
  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;
1471
+ const item = wireBody(entry);
1472
+ if (item === null) continue;
1473
+ const source = item.sources[0];
1628
1474
  const messageId = typeof source?.messageId === "string" ? source.messageId : void 0;
1629
1475
  const startOffset = typeof source?.startOffset === "number" ? source.startOffset : void 0;
1630
1476
  const endOffset = typeof source?.endOffset === "number" ? source.endOffset : void 0;
1631
1477
  annotations.push({
1632
- title: previewTitle(body),
1633
- text: body,
1634
- comment: typeof record.annotation === "string" ? record.annotation : "",
1478
+ title: previewTitle(item.body),
1479
+ text: item.body,
1480
+ comment: item.comment,
1635
1481
  ...messageId ? { messageId } : {},
1636
1482
  ...startOffset !== void 0 ? { startOffset } : {},
1637
1483
  ...endOffset !== void 0 ? { endOffset } : {}
@@ -1642,6 +1488,33 @@ function scanAnnotatedMessage(text) {
1642
1488
  rest
1643
1489
  };
1644
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
+ }
1645
1518
  //#endregion
1646
1519
  //#region src/client/runtime.ts
1647
1520
  /**
@@ -1689,6 +1562,9 @@ function persist(partitionKey) {
1689
1562
  if (!persistence || partitionKey === MEMORY_KEY) return;
1690
1563
  persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
1691
1564
  }
1565
+ function newAnnotationId() {
1566
+ return `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1567
+ }
1692
1568
  /** Append one selection; an identical text+comment pair is reused. */
1693
1569
  function saveAnnotation(sessionId, input) {
1694
1570
  const k = partition(sessionId);
@@ -1697,7 +1573,7 @@ function saveAnnotation(sessionId, input) {
1697
1573
  const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
1698
1574
  if (duplicate) return duplicate;
1699
1575
  const annotation = {
1700
- id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1576
+ id: newAnnotationId(),
1701
1577
  text: input.text,
1702
1578
  ...comment ? { comment } : {},
1703
1579
  sources: input.sources?.length ? input.sources : [{ text: input.text }]
@@ -1707,6 +1583,36 @@ function saveAnnotation(sessionId, input) {
1707
1583
  notify();
1708
1584
  return annotation;
1709
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
+ }
1710
1616
  function getAnnotations(sessionId) {
1711
1617
  return bySession.get(partition(sessionId)) ?? EMPTY$1;
1712
1618
  }
@@ -1888,11 +1794,6 @@ const zh = {
1888
1794
  "toolbar.commentDialogAria": "添加可选评论",
1889
1795
  "toolbar.skipComment": "暂不评论",
1890
1796
  "toolbar.addComment": "添加评论",
1891
- "card.commentSubtitle": "评论 · {comment}",
1892
- "card.ariaIndexed": "批注 {index}",
1893
- "card.ariaPlain": "划词批注",
1894
- "card.editComment": "编辑评论",
1895
- "card.remove": "移除批注",
1896
1797
  "summary.hint": "悬停查看批注",
1897
1798
  "summary.dialogAria": "{count} 条划词批注详情",
1898
1799
  "summary.edit": "编辑批注 {index}",
@@ -1918,11 +1819,6 @@ const en = {
1918
1819
  "toolbar.commentDialogAria": "Add an optional comment",
1919
1820
  "toolbar.skipComment": "Skip comment",
1920
1821
  "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
1822
  "summary.hint": "Hover to view annotations",
1927
1823
  "summary.dialogAria": "Annotation details: {count}",
1928
1824
  "summary.edit": "Edit annotation {index}",
@@ -1955,11 +1851,6 @@ const ru = {
1955
1851
  "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1956
1852
  "toolbar.skipComment": "Без комментария",
1957
1853
  "toolbar.addComment": "Добавить комментарий",
1958
- "card.commentSubtitle": "Комментарий · {comment}",
1959
- "card.ariaIndexed": "Аннотация {index}",
1960
- "card.ariaPlain": "Аннотация к выделенному тексту",
1961
- "card.editComment": "Изменить комментарий",
1962
- "card.remove": "Удалить аннотацию",
1963
1854
  "summary.hint": "Наведите, чтобы увидеть аннотации",
1964
1855
  "summary.dialogAria": "Детали аннотаций: {count}",
1965
1856
  "summary.edit": "Изменить аннотацию {index}",
@@ -2057,52 +1948,6 @@ function useLocale() {
2057
1948
  }
2058
1949
  //#endregion
2059
1950
  //#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
1951
  /** Grace period so pointer transit between the summary and the panel never closes it. */
2107
1952
  const CLOSE_DELAY = 320;
2108
1953
  /** After a viewport change, wait this long before position-checking the pointer. */
@@ -2464,12 +2309,13 @@ function clearDraftMarker(draft, setDraft) {
2464
2309
  * composer, or the primary send button) so the composer's submit path carries
2465
2310
  * the JSON protocol block.
2466
2311
  */
2467
- function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2312
+ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef }) {
2468
2313
  (0, react.useEffect)(() => {
2469
2314
  if (itemCount === 0) return;
2470
2315
  const inject = () => {
2471
2316
  const current = getAnnotations();
2472
2317
  if (current.length === 0) return;
2318
+ if (foldingRef !== void 0) foldingRef.current = true;
2473
2319
  setDraft(composeAnnotatedMessage(draftRef.current, current));
2474
2320
  clearAnnotations();
2475
2321
  onFolded();
@@ -2505,7 +2351,8 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2505
2351
  itemCount,
2506
2352
  draftRef,
2507
2353
  setDraft,
2508
- onFolded
2354
+ onFolded,
2355
+ foldingRef
2509
2356
  ]);
2510
2357
  }
2511
2358
  //#endregion
@@ -2550,6 +2397,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2550
2397
  const draftRef = (0, react.useRef)(draft);
2551
2398
  const actionsRef = (0, react.useRef)(inputActions);
2552
2399
  const sessionRef = (0, react.useRef)(sessionId);
2400
+ const foldingRef = (0, react.useRef)(false);
2553
2401
  const composing = useComposing();
2554
2402
  draftRef.current = draft;
2555
2403
  actionsRef.current = inputActions;
@@ -2595,8 +2443,26 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2595
2443
  itemCount: items.length,
2596
2444
  draftRef,
2597
2445
  setDraft,
2598
- onFolded: () => setItems(EMPTY)
2446
+ onFolded: () => setItems(EMPTY),
2447
+ foldingRef
2599
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
+ ]);
2600
2466
  (0, react.useEffect)(() => {
2601
2467
  if (composing.current) return;
2602
2468
  if (items.length === 0) {
@@ -2683,6 +2549,21 @@ const layers = /* @__PURE__ */ new WeakMap();
2683
2549
  /** flow item → layer, so scrolling does not re-walk the ancestor chain. */
2684
2550
  const itemLayers = /* @__PURE__ */ new WeakMap();
2685
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
+ }
2686
2567
  function contentCoords(scroller, left, top) {
2687
2568
  const rect = scroller.getBoundingClientRect();
2688
2569
  return {
@@ -2706,7 +2587,11 @@ function layerFor(scroller) {
2706
2587
  scroller,
2707
2588
  bounds: () => {
2708
2589
  const rect = scroller.getBoundingClientRect();
2709
- return new DOMRect(rect.left + scroller.clientLeft, rect.top + scroller.clientTop, scroller.clientWidth, scroller.clientHeight);
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));
2710
2595
  },
2711
2596
  toContent: (left, top) => contentCoords(scroller, left, top),
2712
2597
  fromContent: (left, top) => {
@@ -2730,7 +2615,10 @@ function fallbackLayer() {
2730
2615
  fallback = {
2731
2616
  host,
2732
2617
  scroller: document.body,
2733
- bounds: () => new DOMRect(0, 0, window.innerWidth, window.innerHeight),
2618
+ bounds: () => {
2619
+ const bottom = composerTop() ?? window.innerHeight;
2620
+ return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
2621
+ },
2734
2622
  toContent: (left, top) => ({
2735
2623
  left,
2736
2624
  top
@@ -3389,16 +3277,82 @@ function watchAnnotationDirectives(root) {
3389
3277
  return () => observer.disconnect();
3390
3278
  }
3391
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
3392
3346
  //#region src/client/transcript/TranscriptQuoteCard.tsx
3393
3347
  /**
3394
- * History-side cards for one user message's annotations.
3348
+ * History-side summary for one user message's annotations.
3395
3349
  *
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.
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.
3402
3356
  */
3403
3357
  function TranscriptQuoteCard({ node }) {
3404
3358
  ensureToolbarStyles();
@@ -3420,94 +3374,126 @@ function TranscriptQuoteCard({ node }) {
3420
3374
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
3421
3375
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
3422
3376
  }, []);
3423
- const onSelect = (0, react.useCallback)((index, text) => {
3424
- scrollToAnnotation(index + 1, text, markIdOf(index));
3425
- }, [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]);
3426
3382
  if (quotes.length === 0) return null;
3427
3383
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3428
3384
  ref: stackRef,
3429
- className: styles.tCardStack,
3385
+ className: styles.tSummary,
3430
3386
  "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",
3387
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
3388
+ count: quotes.length,
3389
+ items: quotes.map((quote, index) => ({
3390
+ id: `dqsm-${index}`,
3444
3391
  title: quote.title,
3445
3392
  text: quote.text,
3446
- comment: quote.comment || void 0,
3447
- index: index + 1
3448
- })
3449
- }, `ann-${index}`))
3393
+ ...quote.comment ? { comment: quote.comment } : {}
3394
+ })),
3395
+ onItemSelect: onSelect
3396
+ })
3450
3397
  });
3451
3398
  }
3452
3399
  //#endregion
3453
3400
  //#region src/client/transcript/UserMessageDisplay.tsx
3454
- function contentImages(content) {
3455
- 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 [];
3456
3424
  const images = [];
3457
- for (const block of content) {
3425
+ for (const block of raw) {
3458
3426
  if (block === null || typeof block !== "object") continue;
3459
3427
  const candidate = block;
3460
- if (candidate.type !== "image" || candidate.attachment === void 0) continue;
3461
- images.push({ attachment: candidate.attachment });
3428
+ if (candidate.type === "image" && candidate.attachment !== void 0) images.push(candidate.attachment);
3462
3429
  }
3463
3430
  return images;
3464
3431
  }
3465
3432
  /**
3466
3433
  * Replacement for the built-in `user` Chat node view.
3467
3434
  *
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.
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.
3471
3442
  *
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.
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.
3480
3448
  */
3481
- function UserMessageDisplay({ node, renderMessageImages }) {
3449
+ function UserMessageDisplay(props) {
3482
3450
  ensureToolbarStyles();
3451
+ const { node, hostUserOf, ...rest } = props;
3483
3452
  const raw = contentBlocksToText(node.data?.content);
3484
3453
  const scanned = scanAnnotatedMessage(raw);
3485
3454
  const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
3486
3455
  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", {
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", {
3490
3477
  className: cx(A.colEnd, styles.userRow),
3491
3478
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3492
3479
  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
- ]
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]
3509
3495
  })
3510
- });
3496
+ })] });
3511
3497
  }
3512
3498
  //#endregion
3513
3499
  //#region src/client/state/annotation-persistence.ts
@@ -3701,6 +3687,7 @@ function apply(ctx) {
3701
3687
  ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
3702
3688
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
3703
3689
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
3690
+ ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
3704
3691
  ctx.effect(() => {
3705
3692
  syncPendingAnnotationJobs(getAnnotations());
3706
3693
  return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
@@ -3732,14 +3719,28 @@ function apply(ctx) {
3732
3719
  order: 1
3733
3720
  }, SessionBridge);
3734
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
+ });
3735
3730
  ctx.slots.inject("conversation.chat.node", () => {
3736
3731
  ctx.slots.register({
3737
3732
  name: "conversation.chat.node",
3738
3733
  key: "user",
3739
- priority: -10
3740
- }, UserMessageDisplay);
3734
+ priority: -10,
3735
+ locale: "chat"
3736
+ }, UserNode);
3741
3737
  });
3742
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
+ }
3743
3744
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3744
3745
  function SessionBridge({ sessionId }) {
3745
3746
  bindAnnotationSession(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "DeepSeek Harness web plugin: annotate text selected in the conversation — the selection rides along with your next message, is marked in the source text, and the model points back at it.",
5
5
  "keywords": [
6
6
  "deepseek",