dsh-select-quote 0.3.21 → 0.4.0

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 +890 -359
  2. package/package.json +3 -2
package/lib/client.js CHANGED
@@ -95,6 +95,8 @@ const zh = {
95
95
  "toolbar.askInSide": "在侧边提问",
96
96
  "toolbar.sideTaskFailed": "侧边提问创建失败",
97
97
  "toolbar.sideTaskUnavailable": "当前无法创建侧边提问",
98
+ "toolbar.sideUnavailable": "侧边提问不可用:宿主的侧边栏服务未就绪",
99
+ "toolbar.translateUnavailable": "翻译不可用:宿主的模型服务未就绪",
98
100
  "sideTask.title": "在侧边提问",
99
101
  "sideTask.placeholder": "就这段原文提问…",
100
102
  "sideTask.send": "发送",
@@ -238,6 +240,8 @@ const en = {
238
240
  "toolbar.askInSide": "Ask in side task",
239
241
  "toolbar.sideTaskFailed": "Could not start the side task",
240
242
  "toolbar.sideTaskUnavailable": "The side task is unavailable here",
243
+ "toolbar.sideUnavailable": "Sidebar questions are unavailable: the host sidebar service is not ready",
244
+ "toolbar.translateUnavailable": "Translation is unavailable: the host model service is not ready",
241
245
  "sideTask.title": "Ask in side task",
242
246
  "sideTask.placeholder": "Ask about this passage…",
243
247
  "sideTask.send": "Send",
@@ -383,6 +387,8 @@ const ja = {
383
387
  "toolbar.askInSide": "サイドで質問",
384
388
  "toolbar.sideTaskFailed": "サイドタスクを開始できませんでした",
385
389
  "toolbar.sideTaskUnavailable": "この画面ではサイドタスクを使えません",
390
+ "toolbar.sideUnavailable": "サイドバー質問は利用できません: ホストのサイドバーサービスが未準備です",
391
+ "toolbar.translateUnavailable": "翻訳は利用できません: ホストのモデルサービスが未準備です",
386
392
  "sideTask.title": "サイドで質問",
387
393
  "sideTask.placeholder": "この箇所について質問…",
388
394
  "sideTask.send": "送信",
@@ -526,6 +532,8 @@ const es = {
526
532
  "toolbar.askInSide": "Preguntar en tarea lateral",
527
533
  "toolbar.sideTaskFailed": "No se pudo iniciar la tarea lateral",
528
534
  "toolbar.sideTaskUnavailable": "La tarea lateral no está disponible aquí",
535
+ "toolbar.sideUnavailable": "Las preguntas laterales no están disponibles: el servicio de barra lateral del host no está listo",
536
+ "toolbar.translateUnavailable": "La traducción no está disponible: el servicio de modelos del host no está listo",
529
537
  "sideTask.title": "Preguntar en tarea lateral",
530
538
  "sideTask.placeholder": "Pregunta sobre este pasaje…",
531
539
  "sideTask.send": "Enviar",
@@ -671,6 +679,8 @@ const fr = {
671
679
  "toolbar.askInSide": "Demander dans une tâche latérale",
672
680
  "toolbar.sideTaskFailed": "Impossible de démarrer la tâche latérale",
673
681
  "toolbar.sideTaskUnavailable": "La tâche latérale n’est pas disponible ici",
682
+ "toolbar.sideUnavailable": "Les questions latérales sont indisponibles : le service de barre latérale de l’hôte n’est pas prêt",
683
+ "toolbar.translateUnavailable": "La traduction est indisponible : le service de modèles de l’hôte n’est pas prêt",
674
684
  "sideTask.title": "Demander dans une tâche latérale",
675
685
  "sideTask.placeholder": "Posez une question sur ce passage…",
676
686
  "sideTask.send": "Envoyer",
@@ -816,6 +826,8 @@ const de = {
816
826
  "toolbar.askInSide": "In Seitenaufgabe fragen",
817
827
  "toolbar.sideTaskFailed": "Seitenaufgabe konnte nicht gestartet werden",
818
828
  "toolbar.sideTaskUnavailable": "Die Seitenaufgabe ist hier nicht verfügbar",
829
+ "toolbar.sideUnavailable": "Seitenfragen sind nicht verfügbar: der Sidebar-Dienst des Hosts ist nicht bereit",
830
+ "toolbar.translateUnavailable": "Übersetzung ist nicht verfügbar: der Modell-Dienst des Hosts ist nicht bereit",
819
831
  "sideTask.title": "In Seitenaufgabe fragen",
820
832
  "sideTask.placeholder": "Frage zu dieser Passage stellen…",
821
833
  "sideTask.send": "Senden",
@@ -961,6 +973,8 @@ const ru = {
961
973
  "toolbar.askInSide": "Спросить в боковой задаче",
962
974
  "toolbar.sideTaskFailed": "Не удалось открыть боковую задачу",
963
975
  "toolbar.sideTaskUnavailable": "Боковая задача недоступна в этом окне",
976
+ "toolbar.sideUnavailable": "Боковые вопросы недоступны: служба боковой панели хоста не готова",
977
+ "toolbar.translateUnavailable": "Перевод недоступен: служба моделей хоста не готова",
964
978
  "sideTask.title": "Спросить в боковой задаче",
965
979
  "sideTask.placeholder": "Спросите об этом фрагменте…",
966
980
  "sideTask.send": "Отправить",
@@ -1110,6 +1124,8 @@ const pt = {
1110
1124
  "toolbar.askInSide": "Perguntar na tarefa lateral",
1111
1125
  "toolbar.sideTaskFailed": "Não foi possível iniciar a tarefa lateral",
1112
1126
  "toolbar.sideTaskUnavailable": "A tarefa lateral não está disponível aqui",
1127
+ "toolbar.sideUnavailable": "Perguntas laterais indisponíveis: o serviço de barra lateral do host não está pronto",
1128
+ "toolbar.translateUnavailable": "A tradução está indisponível: o serviço de modelos do host não está pronto",
1113
1129
  "sideTask.title": "Perguntar na tarefa lateral",
1114
1130
  "sideTask.placeholder": "Pergunte sobre esta passagem…",
1115
1131
  "sideTask.send": "Enviar",
@@ -1544,7 +1560,7 @@ const COMPONENTS_CSS = [
1544
1560
  --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));
1545
1561
  /* Every surface above takes the same elevation; declaring it once here is
1546
1562
  what keeps the component blocks free of a copied box-shadow. */
1547
- box-shadow: var(--dsq-shadow);
1563
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.06));
1548
1564
  }
1549
1565
 
1550
1566
  /* 浮层挂载与层带(2026-10-20 定,改前先读):
@@ -1623,6 +1639,48 @@ const COMPONENTS_CSS = [
1623
1639
  background: var(--dsq-fill-hover);
1624
1640
  }
1625
1641
 
1642
+ /* An entry point the Host cannot run right now stays in the row, dimmed, and
1643
+ explains itself on hover. Removing it instead would read as a missing
1644
+ feature; a click that only produces a toast makes the user hunt for why. */
1645
+ .dsq_button[aria-disabled='true'],
1646
+ .dsq_touchAction[aria-disabled='true'],
1647
+ .dsq_ctxItem[aria-disabled='true'],
1648
+ .dsq_handoffChevron[aria-disabled='true'] {
1649
+ opacity: 0.42;
1650
+ cursor: default;
1651
+ }
1652
+
1653
+ .dsq_button[aria-disabled='true']:hover {
1654
+ background: transparent;
1655
+ }
1656
+
1657
+ /* Hover tooltip. A native title would be slower and styled by the OS; this
1658
+ rides the same token set as the rest of the chrome. */
1659
+ [data-dsq-tip] {
1660
+ position: relative;
1661
+ }
1662
+
1663
+ [data-dsq-tip]:hover::after {
1664
+ content: attr(data-dsq-tip);
1665
+ position: absolute;
1666
+ top: calc(100% + 8px);
1667
+ left: 50%;
1668
+ z-index: 4;
1669
+ max-width: 240px;
1670
+ padding: 5px 9px;
1671
+ border-radius: 8px;
1672
+ border: 0.5px solid var(--dsq-line);
1673
+ background: var(--dsq-bg-raised);
1674
+ color: var(--dsq-label);
1675
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.08));
1676
+ font-size: 11.5px;
1677
+ line-height: 16px;
1678
+ font-weight: 400;
1679
+ white-space: normal;
1680
+ pointer-events: none;
1681
+ transform: translateX(-50%);
1682
+ }
1683
+
1626
1684
  /* Accent-filled actions: comment commit and annotation save. Only the
1627
1685
  metrics differ; fill and hover live here once. */
1628
1686
  .dsq_buttonAddComment,
@@ -1700,16 +1758,30 @@ const COMPONENTS_CSS = [
1700
1758
  only where they anchor differs. */
1701
1759
  .dsq_status,
1702
1760
  .dsq_touchStatus {
1703
- padding: 4px 10px;
1761
+ display: inline-flex;
1762
+ align-items: center;
1763
+ gap: 6px;
1764
+ padding: 5px 11px 5px 9px;
1704
1765
  border-radius: 999px;
1705
- background: var(--dsq-bg-toast);
1706
- color: var(--dsq-label-inverted);
1766
+ border: 0.5px solid var(--dsq-toast-line);
1767
+ /* Surface colours, not an inverted pill: a light page gets a light chip with
1768
+ dark text, a dark page a dark chip with light text. The hairline keeps the
1769
+ chip readable when it sits on a same-tone bubble. */
1770
+ background: var(--dsq-toast-bg);
1771
+ color: var(--dsq-toast-fg);
1772
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.06));
1707
1773
  font-size: 12px;
1708
1774
  line-height: 18px;
1709
1775
  pointer-events: none;
1710
1776
  white-space: nowrap;
1711
1777
  }
1712
1778
 
1779
+ /* Every current status is a failure notice, so the icon is the error glyph. */
1780
+ .dsq_statusIcon {
1781
+ flex: none;
1782
+ color: var(--dsq-danger);
1783
+ }
1784
+
1713
1785
  .dsq_status {
1714
1786
  position: absolute;
1715
1787
  z-index: 3;
@@ -2120,8 +2192,9 @@ const COMPONENTS_CSS = [
2120
2192
  left: 8px;
2121
2193
  z-index: 12;
2122
2194
  width: 20%;
2123
- min-width: 148px;
2124
- max-width: 220px;
2195
+ /* Wide enough for the title and an inline confirm chip on one line. */
2196
+ min-width: 210px;
2197
+ max-width: 240px;
2125
2198
  align-items: stretch;
2126
2199
  gap: 6px;
2127
2200
  pointer-events: auto;
@@ -2137,6 +2210,9 @@ const COMPONENTS_CSS = [
2137
2210
  width: 20px;
2138
2211
  height: 20px;
2139
2212
  padding: 0;
2213
+ /* On exit the confirm chip hands the corner back to this 20px button; the
2214
+ label inside is still fading, so clip it instead of letting it spill. */
2215
+ overflow: hidden;
2140
2216
  border-radius: 6px;
2141
2217
  color: var(--dsq-label-3);
2142
2218
  font-size: 14px;
@@ -2293,9 +2369,13 @@ const COMPONENTS_CSS = [
2293
2369
  }
2294
2370
 
2295
2371
  .dsq_summaryItem {
2372
+ position: relative;
2296
2373
  display: block;
2297
2374
  padding: 8px 10px;
2298
2375
  border-radius: 8px;
2376
+ /* The confirm pill overlays the row; clip it to the card so a very narrow
2377
+ summary can never push content outside the visible surface. */
2378
+ overflow: hidden;
2299
2379
  transition: background-color 0.12s;
2300
2380
  }
2301
2381
 
@@ -2304,10 +2384,13 @@ const COMPONENTS_CSS = [
2304
2384
  }
2305
2385
 
2306
2386
  .dsq_summaryItemHead {
2387
+ position: relative;
2307
2388
  gap: 6px;
2308
2389
  /* Normal mode's action buttons set the row height; keep it in edit mode too
2309
2390
  so the badge and the label never move when the actions disappear. */
2310
2391
  min-height: 26px;
2392
+ /* The confirm pill and the row label both clip here, never squeeze the row. */
2393
+ min-width: 0;
2311
2394
  }
2312
2395
 
2313
2396
  .dsq_summaryItemBadge {
@@ -2345,6 +2428,106 @@ const COMPONENTS_CSS = [
2345
2428
  color: var(--dsq-label);
2346
2429
  }
2347
2430
 
2431
+ /* Second step of a remove: the same button grows a "确认移除" label.
2432
+ *
2433
+ * Layout here is load-bearing. The summary card is 20% wide (min 148px) and the
2434
+ * head row is a flex line that already holds the ordinal badge and the
2435
+ * "选中文本" label. Growing a label *inside* that row cannot work: whatever the
2436
+ * button asks for, the flex line only has the leftover width, and a shrinkable
2437
+ * flex item ends up a few pixels wide with the label wrapping one character per
2438
+ * line — the collapse this rule exists to prevent.
2439
+ *
2440
+ * So the confirm step leaves the flow entirely: the button keeps its 26px
2441
+ * square footprint (nothing shifts), and the "确认移除" pill is an absolutely
2442
+ * positioned overlay measured against that square, so the badge and the row's
2443
+ * label can never squeeze it. */
2444
+ .dsq_summaryItemActions {
2445
+ position: relative;
2446
+ min-width: 0;
2447
+ }
2448
+ /* Second step of a remove: the same button becomes an in-flow pill.
2449
+ *
2450
+ * The shared rule (preview.ts) already grows a "确认移除" label by animating its
2451
+ * max-width. That animation only reads when the button stays in flow: the
2452
+ * sibling edit button slides left as the label grows. So this rule deliberately
2453
+ * does NOT position the button or its label — the original breakage was a flex
2454
+ * squeeze (the row shrank the growing button to a few pixels and the label
2455
+ * wrapped one character per line), not placement. flex:none plus nowrap keeps
2456
+ * the pill at its natural width; the row label truncates instead, so nothing
2457
+ * is covered and nothing wraps. */
2458
+ .dsq_summaryItemActions .dsq_deleteConfirm {
2459
+ flex: none;
2460
+ white-space: nowrap;
2461
+ }
2462
+
2463
+ /* Collapsed label reserves no width, so the pill only occupies what it shows. */
2464
+ .dsq_summaryItemActions .dsq_deleteConfirm .dsq_deleteConfirmInner {
2465
+ white-space: nowrap;
2466
+ }
2467
+
2468
+ /* The row label yields width to the growing pill rather than competing. */
2469
+ .dsq_summaryItemHead .dsq_summaryItemLabel {
2470
+ min-width: 0;
2471
+ margin-top: 0;
2472
+ overflow: hidden;
2473
+ text-overflow: ellipsis;
2474
+ white-space: nowrap;
2475
+ }
2476
+
2477
+ /* "移除全部" on the summary card, second step.
2478
+ *
2479
+ * The chip keeps the icon button's corner (absolute, right-anchored) and only
2480
+ * claims a fixed width, so it grows leftwards without touching the card layout:
2481
+ * the badge, the title and the hint stay exactly where they were, with no
2482
+ * reflow and no jitter. Because cardClose already sits in that corner, the
2483
+ * confirm step is the same control in the same place — it just gets wider.
2484
+ *
2485
+ * Fixed rather than shrink-to-fit: the width has to hold "× 确认移除" at the
2486
+ * smallest text size the app uses, and a fixed value keeps the two states
2487
+ * visually interchangeable. */
2488
+ .dsq_summary > .dsq_deleteConfirm {
2489
+ position: absolute;
2490
+ top: 4px;
2491
+ right: 4px;
2492
+ z-index: 3;
2493
+ display: inline-flex;
2494
+ align-items: center;
2495
+ justify-content: center;
2496
+ width: 80px;
2497
+ height: 26px;
2498
+ padding: 0;
2499
+ gap: 0;
2500
+ white-space: nowrap;
2501
+ }
2502
+
2503
+ .dsq_summary > .dsq_deleteConfirm .dsq_deleteConfirmInner {
2504
+ display: inline-flex;
2505
+ align-items: center;
2506
+ justify-content: center;
2507
+ white-space: nowrap;
2508
+ }
2509
+
2510
+ /* The pill reads "× 确认移除": the icon already precedes the label in the DOM,
2511
+ so in flow the two sit side by side inside one chip. */
2512
+ .dsq_summary > .dsq_deleteConfirm .dsq_deleteConfirmInner {
2513
+ display: inline-flex;
2514
+ align-items: center;
2515
+ white-space: nowrap;
2516
+ }
2517
+
2518
+ /* Body gives way by truncating, never by wrapping. */
2519
+ .dsq_summaryBody {
2520
+ min-width: 0;
2521
+ }
2522
+
2523
+ .dsq_summaryTitle,
2524
+ .dsq_summaryHint {
2525
+ min-width: 0;
2526
+ overflow: hidden;
2527
+ text-overflow: ellipsis;
2528
+ white-space: nowrap;
2529
+ }
2530
+
2348
2531
  .dsq_summaryItemMain {
2349
2532
  min-width: 0;
2350
2533
  /* The lead-in lives on the shared wrapper, not on the normal-mode button, so
@@ -2411,6 +2594,26 @@ const COMPONENTS_CSS = [
2411
2594
  color: var(--dsq-label-3);
2412
2595
  }
2413
2596
 
2597
+ /* A user message that carries annotations but no words.
2598
+ *
2599
+ * The bubble is Host-rendered now, and the Host decides to show it from the raw
2600
+ * content: an annotation-only message still has content (the protocol block), so
2601
+ * the bubble paints — but our userText rule renders that block as empty text, so
2602
+ * what remains is an empty rounded box above the card. Hide it, and hide it only
2603
+ * when the bubble really holds nothing else:
2604
+ *
2605
+ * bubble > (only child) userText root > (only child) text seat > (only child) empty run
2606
+ *
2607
+ * Any attachment, file card, reference summary or extra JSON block adds a
2608
+ * sibling at one of those levels and keeps the bubble visible. */
2609
+ [data-message-bubble]:has(
2610
+ > span:only-child
2611
+ > span[data-slot='conversation.user.text.node']:only-child
2612
+ > span:empty:only-child
2613
+ ) {
2614
+ display: none;
2615
+ }
2616
+
2414
2617
  /* Inline edit form rendered inside the hover panel item (no extra layer).
2415
2618
  Shares .dsq_summaryItemText with the read view, but shows the full quote
2416
2619
  instead of clamp-reading it. */
@@ -2560,6 +2763,10 @@ const COMPONENTS_CSS = [
2560
2763
 
2561
2764
  .dsq_translateHead {
2562
2765
  gap: 6px;
2766
+ /* Without this the row stretches its children: the 24px close button then
2767
+ pins to the top of a taller row, so its hover plate looks detached from
2768
+ the icon. */
2769
+ align-items: center;
2563
2770
  }
2564
2771
 
2565
2772
  .dsq_translateTargetLabel {
@@ -2571,9 +2778,9 @@ const COMPONENTS_CSS = [
2571
2778
 
2572
2779
  .dsq_translateClose {
2573
2780
  flex: none;
2574
- width: 24px;
2575
- height: 24px;
2576
- border-radius: 7px;
2781
+ width: 28px;
2782
+ height: 28px;
2783
+ border-radius: 8px;
2577
2784
  color: var(--dsq-label-3);
2578
2785
  }
2579
2786
 
@@ -2751,9 +2958,30 @@ const COMPONENTS_CSS = [
2751
2958
 
2752
2959
  .dsq_translateActions {
2753
2960
  justify-content: flex-end;
2961
+ align-items: center;
2754
2962
  gap: 6px;
2755
2963
  }
2756
2964
 
2965
+ /* Copy result: an icon button in the actions row, matching the close control's
2966
+ footprint so the two read as one family. The check glyph is the copied
2967
+ confirmation; the label stays on aria-label/title. */
2968
+ .dsq_translateCopy {
2969
+ flex: none;
2970
+ width: 28px;
2971
+ height: 28px;
2972
+ border-radius: 8px;
2973
+ color: var(--dsq-label-2);
2974
+ }
2975
+
2976
+ .dsq_translateCopy:hover {
2977
+ background: var(--dsq-fill-hover);
2978
+ color: var(--dsq-label);
2979
+ }
2980
+
2981
+ .dsq_translateCopy[data-copied='true'] {
2982
+ color: var(--dsq-accent);
2983
+ }
2984
+
2757
2985
  /* Language dropdown shared by the popover and the settings page. The trigger
2758
2986
  sits inline; the menu is a body-level frosted list so neither surface can
2759
2987
  clip it. */
@@ -2932,13 +3160,19 @@ const COMPONENTS_CSS = [
2932
3160
  white-space: nowrap;
2933
3161
  }
2934
3162
 
3163
+ /* Exit transition (this rule is the state the label lands in when confirm is
3164
+ cancelled). Text leaves first and the box collapses afterwards: a 50ms fade
3165
+ followed by a delayed width run, so the eye never tracks a half-transparent
3166
+ label sliding into the icon. The entering direction is declared on the
3167
+ confirming rule below, which is the state the label lands in on the way in —
3168
+ CSS uses the transition of the destination state, not the origin. */
2935
3169
  .dsq_deleteConfirmLabel {
2936
3170
  max-width: 0;
2937
3171
  margin-left: 0;
2938
3172
  overflow: hidden;
2939
3173
  opacity: 0;
2940
3174
  white-space: nowrap;
2941
- transition: max-width 0.16s ease, margin-left 0.16s ease, opacity 0.12s ease;
3175
+ transition: opacity 0.05s ease, max-width 0.12s ease 0.05s, margin-left 0.12s ease 0.05s;
2942
3176
  }
2943
3177
 
2944
3178
  /* One grid item, so the icon-only state stays centered in the 26/20px button
@@ -2948,10 +3182,12 @@ const COMPONENTS_CSS = [
2948
3182
  align-items: center;
2949
3183
  }
2950
3184
 
3185
+ /* Enter transition: the box opens first, the text fades in once it has room. */
2951
3186
  .dsq_deleteConfirm[data-confirming='true'] .dsq_deleteConfirmLabel {
2952
3187
  max-width: 140px;
2953
3188
  margin-left: 4px;
2954
3189
  opacity: 1;
3190
+ transition: max-width 0.14s ease, margin-left 0.14s ease, opacity 0.1s ease 0.06s;
2955
3191
  }
2956
3192
 
2957
3193
  /* Touch selection chrome. Fixed to the visual viewport and mounted on body:
@@ -3169,66 +3405,59 @@ const COMPONENTS_CSS = [
3169
3405
  transition: background-color 0.12s;
3170
3406
  }
3171
3407
 
3172
- /* Ordinal as a superscript corner badge at the END of the marked text, so it
3173
- never sits between the reader and the sentence. Only the last fragment of a
3174
- multi-fragment mark carries the index, so the number appears exactly once.
3175
- Absolute so the badge adds no inline advance width: annotating never reflows
3176
- the paragraph or moves following words. Bottom (not top) anchors to the last
3177
- fragment: for a mark spanning several lines the containing block is the union
3178
- of the first and last fragments, so its bottom edge is the last line.
3179
- BADGE_BOTTOM / BADGE_HEIGHT / the 2px margin in annotate-text.ts mirror the
3180
- numbers below for collision measurement. */
3181
- .dsq_annMark[data-dsq-ann-index]::after {
3182
- content: attr(data-dsq-ann-index);
3408
+ /* Badge painted at the END of a mark: the ordinal, the handoff arrow, or the
3409
+ bot glyph for a side question.
3410
+ A real element, not a ::after. A mark that wraps is one inline box spanning
3411
+ several lines, and percentage offsets resolve against a width unrelated to
3412
+ where the text actually ends (measured: a 678px two-line mark resolved
3413
+ +left: 100%+ to 0, a 91px one-line mark to 90.88px). annotate-text.ts
3414
+ therefore measures the last line fragment and writes left/top in pixels;
3415
+ collision offsets become a transform on this element alone. */
3416
+ .dsq_annBadge {
3183
3417
  position: absolute;
3184
- bottom: 8px;
3185
- left: 100%;
3186
- /* Above the native selection highlight and the background of a later mark:
3187
- both otherwise paint over the badge when their ranges meet. */
3188
- z-index: 2;
3189
- /* JS shifts colliding badges right/up; the resting position is unchanged. */
3190
- transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
3191
3418
  display: grid;
3192
3419
  place-items: center;
3193
3420
  min-width: 15px;
3194
3421
  height: 15px;
3195
- margin-left: 2px;
3196
3422
  padding: 0 3px;
3197
3423
  border-radius: 999px;
3198
3424
  background: var(--dsq-mark-color);
3199
3425
  color: #fff;
3200
3426
  font-size: 10px;
3427
+ font-style: normal;
3201
3428
  font-weight: 600;
3202
3429
  line-height: 1;
3203
- /* The only click target on a mark; the prose itself stays selectable. */
3204
- pointer-events: auto;
3205
- cursor: pointer;
3430
+ /* Above the native selection highlight and the background of a later mark:
3431
+ both otherwise paint over the badge when their ranges meet. */
3432
+ z-index: 2;
3433
+ /* The mark stays the click target; the badge is decoration. */
3434
+ pointer-events: none;
3435
+ /* Never part of a text selection. Otherwise Ctrl+A or a drag across the
3436
+ sentence includes the glyph, and the native highlight paints a coloured
3437
+ box behind a badge that is meant to look like a superscript label. */
3438
+ user-select: none;
3439
+ -webkit-user-select: none;
3440
+ }
3441
+
3442
+ /* Side-question mark: the lucide bot glyph instead of the ordinal, drawn in
3443
+ white to match the badge ink on the accent fill. The glyph is a data URI
3444
+ because the badge is a single element with no room for an inline svg. */
3445
+ .dsq_annBadge[data-dsq-ann-robot] {
3446
+ /* Same padding as an ordinal badge, so the two read as one control; the
3447
+ glyph shrinks to the content box (15px min-width - 6px padding). */
3448
+ padding: 0 3px;
3449
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 8V4H8'/%3E%3Crect width='16' height='12' x='4' y='8' rx='2'/%3E%3Cpath d='M2 14h2'/%3E%3Cpath d='M20 14h2'/%3E%3Cpath d='M15 13v2'/%3E%3Cpath d='M9 13v2'/%3E%3C/svg%3E");
3450
+ background-repeat: no-repeat;
3451
+ background-position: center;
3452
+ background-size: 9px 9px;
3206
3453
  }
3207
3454
 
3208
3455
  /* Arrow badge for a selection sent to another conversation. One glyph, so the
3209
3456
  width is fixed instead of digit-based; the box matches the ordinal badge so
3210
3457
  collision offsets stay interchangeable. */
3211
- .dsq_annMark[data-dsq-ann-arrow]::after {
3212
- content: "↗";
3213
- position: absolute;
3214
- bottom: 8px;
3215
- left: 100%;
3216
- z-index: 2;
3217
- transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
3218
- display: grid;
3219
- place-items: center;
3458
+ .dsq_annBadge[data-dsq-ann-arrow] {
3220
3459
  width: 15px;
3221
- height: 15px;
3222
- margin-left: 2px;
3223
3460
  padding: 0;
3224
- border-radius: 999px;
3225
- background: var(--dsq-mark-color);
3226
- color: #fff;
3227
- font-size: 10px;
3228
- font-weight: 600;
3229
- line-height: 1;
3230
- pointer-events: auto;
3231
- cursor: pointer;
3232
3461
  }
3233
3462
 
3234
3463
  .dsq_annMark:hover {
@@ -3951,7 +4180,14 @@ body {
3951
4180
  --dsq-font-size: var(--dsh-content-font-size, 14px);
3952
4181
  --dsq-font-delta: var(--dsh-content-font-delta, 0px);
3953
4182
  --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
3954
- --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
4183
+ /* Toast pill. The product token that used to back this (bg-layer-3) is plain
4184
+ white in dsh 0.2.2 while the label inverted token is white too — a white
4185
+ pill with white text. Pair two tokens that are guaranteed to be each
4186
+ other's inverse instead: primary label as the surface, raised surface as
4187
+ the ink. Light theme gives dark-on-light, dark theme flips with it. */
4188
+ --dsq-toast-bg: var(--dsq-bg-raised);
4189
+ --dsq-toast-fg: var(--dsq-label);
4190
+ --dsq-toast-line: var(--dsq-line);
3955
4191
  /* The settings switch mirrors the Host's own switch: neutral track when off,
3956
4192
  brand fill when on, and a thumb that inverts with the theme. */
3957
4193
  --dsq-switch-off: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.12));
@@ -4033,6 +4269,7 @@ const styles = {
4033
4269
  kbd: "dsq_kbd",
4034
4270
  divider: "dsq_divider",
4035
4271
  status: "dsq_status",
4272
+ statusIcon: "dsq_statusIcon",
4036
4273
  ctxMenu: "dsq_ctxMenu",
4037
4274
  ctxItem: "dsq_ctxItem",
4038
4275
  modelSeat: "dsq_modelSeat",
@@ -4100,6 +4337,7 @@ const styles = {
4100
4337
  annDirective: "dsq_annDirective",
4101
4338
  annAnchor: "dsq_annAnchor",
4102
4339
  annMark: "dsq_annMark",
4340
+ annBadge: "dsq_annBadge",
4103
4341
  annMarkFlash: "dsq_annMarkFlash",
4104
4342
  annMessageFlash: "dsq_annMessageFlash",
4105
4343
  handoffRow: "dsq_handoffRow",
@@ -4135,6 +4373,7 @@ const styles = {
4135
4373
  translateHead: "dsq_translateHead",
4136
4374
  translateTargetLabel: "dsq_translateTargetLabel",
4137
4375
  translateClose: "dsq_translateClose",
4376
+ translateCopy: "dsq_translateCopy",
4138
4377
  translateBody: "dsq_translateBody",
4139
4378
  translateText: "dsq_translateText",
4140
4379
  translateHint: "dsq_translateHint",
@@ -4506,6 +4745,7 @@ function cleanTextOf(item) {
4506
4745
  */
4507
4746
  const MARK_ATTR = "data-dsq-ann-mark";
4508
4747
  const INDEX_ATTR = "data-dsq-ann-index";
4748
+ const ROBOT_ATTR = "data-dsq-ann-robot";
4509
4749
  const ARROW_ATTR = "data-dsq-ann-arrow";
4510
4750
  const FLASH_MS = 1500;
4511
4751
  const jobs = /* @__PURE__ */ new Map();
@@ -4597,6 +4837,18 @@ function sameText(a, b) {
4597
4837
  return norm(a) === norm(b);
4598
4838
  }
4599
4839
  /**
4840
+ * The mark's own prose, without the badge glyph.
4841
+ *
4842
+ * The badge is a child element carrying the ordinal or the arrow, so plain
4843
+ * `textContent` returns "…text1" and never equals the annotation text.
4844
+ */
4845
+ function markTextOf(mark) {
4846
+ let text = "";
4847
+ for (const node of mark.childNodes) if (node instanceof Text) text += node.data;
4848
+ else if (node instanceof Element && !node.classList.contains(styles.annBadge)) text += node.textContent ?? "";
4849
+ return text;
4850
+ }
4851
+ /**
4600
4852
  * Already decorated by THIS job. A different job may wrap the same span (a
4601
4853
  * local pending annotation and the outbound arrow of the same selection are
4602
4854
  * separate marks), so the check must be by mark id, not by any mark.
@@ -4623,6 +4875,24 @@ function makeArrowInteractive(mark, targetLabel) {
4623
4875
  const label = t("handoff.markAria", { target: targetLabel ?? "" });
4624
4876
  if (mark.getAttribute("aria-label") !== label) mark.setAttribute("aria-label", label);
4625
4877
  }
4878
+ /**
4879
+ * Attach the badge element to the mark's last fragment.
4880
+ *
4881
+ * Called **after** the mark is in the document: `Range.surroundContents()`
4882
+ * clears the new parent's children before moving the range into it, so a badge
4883
+ * appended beforehand is silently dropped.
4884
+ */
4885
+ function attachBadge(mark, job) {
4886
+ const badge = document.createElement("i");
4887
+ badge.className = styles.annBadge;
4888
+ badge.setAttribute("aria-hidden", "true");
4889
+ if (job.variant === "arrow") {
4890
+ badge.setAttribute(ARROW_ATTR, "true");
4891
+ badge.textContent = "↗";
4892
+ } else if (job.variant === "robot") badge.setAttribute(ROBOT_ATTR, "true");
4893
+ else badge.textContent = String(job.index);
4894
+ mark.append(badge);
4895
+ }
4626
4896
  function createMark(job, withIndex) {
4627
4897
  const mark = document.createElement("span");
4628
4898
  mark.className = styles.annMark;
@@ -4664,8 +4934,10 @@ function wrapRange(item, job, from, to) {
4664
4934
  const range = document.createRange();
4665
4935
  range.setStart(node, piece.start - piece.entry.start);
4666
4936
  range.setEnd(node, piece.end - piece.entry.start);
4667
- const mark = createMark(job, i === pieces.length - 1);
4937
+ const last = i === pieces.length - 1;
4938
+ const mark = createMark(job, last);
4668
4939
  range.surroundContents(mark);
4940
+ if (last) attachBadge(mark, job);
4669
4941
  wrapped = true;
4670
4942
  });
4671
4943
  return wrapped;
@@ -4742,11 +5014,54 @@ function refreshAnnotationMarkLabels() {
4742
5014
  * never participates in text layout. Collisions are resolved inside each
4743
5015
  * message block, and a stack may not climb into the message above.
4744
5016
  */
5017
+ /** The badge element of one mark, when it has one. */
5018
+ function badgeOf(mark) {
5019
+ return mark.querySelector(`.${styles.annBadge}`);
5020
+ }
5021
+ /**
5022
+ * Position one badge at the end of its mark's **last line fragment**.
5023
+ *
5024
+ * `left: 100%` cannot do this: a mark that wraps is one inline box spanning
5025
+ * several lines, and the percentage resolves against a width that has nothing
5026
+ * to do with where the text actually ends (measured: a 678px-wide two-line mark
5027
+ * resolved to `left: 0`, another 91px-wide one to `left: 90.88px`). So the
5028
+ * badge is a real element placed in pixels: measure the last client rect of a
5029
+ * range over the mark's text, then convert it into the mark's own coordinate
5030
+ * space.
5031
+ */
5032
+ function placeBadge(mark, badge) {
5033
+ const text = mark.firstChild;
5034
+ if (!(text instanceof Text) || text.length === 0) return null;
5035
+ const range = document.createRange();
5036
+ range.setStart(text, 0);
5037
+ range.setEnd(text, text.length);
5038
+ const rects = Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0);
5039
+ const last = rects[rects.length - 1];
5040
+ if (last === void 0) return null;
5041
+ badge.style.left = "0px";
5042
+ badge.style.top = "0px";
5043
+ badge.style.transform = "none";
5044
+ const origin = badge.getBoundingClientRect();
5045
+ if (origin.width === 0 && origin.height === 0) return null;
5046
+ const height = origin.height || 15;
5047
+ const dx = last.right + 2 - origin.left;
5048
+ const dy = last.bottom - height - 8 - origin.top;
5049
+ badge.style.transform = `translate(${dx}px, ${dy}px)`;
5050
+ return {
5051
+ dx,
5052
+ dy
5053
+ };
5054
+ }
4745
5055
  function layoutBadges(scope) {
4746
5056
  const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}], [${ARROW_ATTR}]`));
5057
+ const badges = /* @__PURE__ */ new Map();
5058
+ const bases = /* @__PURE__ */ new WeakMap();
4747
5059
  for (const mark of marks) {
4748
- mark.style.removeProperty("--dsq-ann-badge-x");
4749
- mark.style.removeProperty("--dsq-ann-badge-y");
5060
+ const badge = badgeOf(mark);
5061
+ if (badge === null) continue;
5062
+ badges.set(mark, badge);
5063
+ const base = placeBadge(mark, badge);
5064
+ if (base !== null) bases.set(badge, base);
4750
5065
  }
4751
5066
  if (marks.length < 2) return;
4752
5067
  const scroller = marks[0]?.closest("[data-conversation-scroll]");
@@ -4765,18 +5080,19 @@ function layoutBadges(scope) {
4765
5080
  const ownerRect = owner?.getBoundingClientRect();
4766
5081
  const topEdge = Math.max(scrollerTop, ownerRect && ownerRect.height > 0 ? ownerRect.top : scrollerTop) + 4;
4767
5082
  const anchors = group.map((mark) => {
4768
- const rect = mark.getBoundingClientRect();
4769
- const arrow = mark.hasAttribute(ARROW_ATTR);
5083
+ const badge = badges.get(mark);
5084
+ if (badge === void 0) return null;
5085
+ const rect = badge.getBoundingClientRect();
4770
5086
  return {
4771
- mark,
5087
+ badge,
4772
5088
  index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
4773
- x: rect.right + 2,
4774
- y: rect.bottom - 8 - 15,
4775
- width: arrow ? 15 : 24,
4776
- height: 15,
5089
+ x: rect.left,
5090
+ y: rect.top,
5091
+ width: rect.width || 24,
5092
+ height: rect.height || 15,
4777
5093
  visible: rect.width > 0 && rect.height > 0
4778
5094
  };
4779
- }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
5095
+ }).filter((anchor) => anchor !== null && anchor.visible).sort((a, b) => a.index - b.index);
4780
5096
  if (anchors.length < 2) continue;
4781
5097
  const offsets = placeBadges(anchors, {
4782
5098
  right: rightEdge - 4,
@@ -4784,11 +5100,16 @@ function layoutBadges(scope) {
4784
5100
  });
4785
5101
  anchors.forEach((anchor, i) => {
4786
5102
  const { dx, dy } = offsets[i];
4787
- if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
4788
- if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
5103
+ const base = bases.get(anchor.badge);
5104
+ if (base === void 0) return;
5105
+ anchor.badge.style.transform = `translate(${base.dx + dx}px, ${base.dy + dy}px)`;
4789
5106
  });
4790
5107
  }
4791
5108
  }
5109
+ /** True when this message row is rendered inside the right sidebar's session pane. */
5110
+ function insideSidePane(node) {
5111
+ return node.closest("[data-sidebar-right-session]") !== null;
5112
+ }
4792
5113
  /**
4793
5114
  * Apply registered jobs. With `only`, touch just those rendered messages — the
4794
5115
  * mutation-driven path — instead of resolving and scanning every message for
@@ -4806,6 +5127,7 @@ function applyAnnotationMarks(root, only) {
4806
5127
  const items = only ? job.messageId === "" ? onlyItems : onlyByKey?.get(job.messageId) ?? [] : job.messageId === "" ? lookup?.all ?? [] : [lookup?.byKey.get(job.messageId)].filter((item) => item !== void 0);
4807
5128
  let decorated = false;
4808
5129
  for (const item of items) {
5130
+ if (job.variant !== "robot" && insideSidePane(item)) continue;
4809
5131
  if (refreshMarkIndex(item, job)) {
4810
5132
  decorated = true;
4811
5133
  continue;
@@ -4817,10 +5139,16 @@ function applyAnnotationMarks(root, only) {
4817
5139
  }
4818
5140
  if (!only) {
4819
5141
  layoutBadges(scope);
5142
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => layoutBadges(scope));
4820
5143
  return;
4821
5144
  }
4822
5145
  for (const item of only) if (item.isConnected) layoutBadges(item);
4823
5146
  }
5147
+ /** Re-place every badge; call after any layout change that moves text. */
5148
+ function relayoutAnnotationMarks(root) {
5149
+ if (typeof document === "undefined") return;
5150
+ layoutBadges(root ?? document.body);
5151
+ }
4824
5152
  /** Insert or refresh jobs; identical jobs are no-ops. */
4825
5153
  function upsertAnnotationJobs(next) {
4826
5154
  let changed = false;
@@ -4959,7 +5287,7 @@ function findAnnotationMark(index, text, markId) {
4959
5287
  const marks = [...document.querySelectorAll(`[${INDEX_ATTR}="${index}"]`)].filter((node) => node instanceof HTMLElement);
4960
5288
  if (marks.length === 0) return null;
4961
5289
  if (text) {
4962
- const hit = marks.find((mark) => sameText(mark.textContent ?? "", text));
5290
+ const hit = marks.find((mark) => sameText(markTextOf(mark), text));
4963
5291
  if (hit) return hit;
4964
5292
  }
4965
5293
  return marks[0];
@@ -5175,86 +5503,10 @@ function openSourceAndLocate(input) {
5175
5503
  };
5176
5504
  window.setTimeout(tick, 0);
5177
5505
  }
5178
- const DRAFT_MARKER_RE = /\u200B/g;
5179
- /** Plain text from Chat user-node content blocks (string or block array). */
5180
- function contentBlocksToText(content) {
5181
- if (typeof content === "string") return content;
5182
- if (!Array.isArray(content)) return "";
5183
- return content.map((block) => {
5184
- if (block && typeof block === "object" && "text" in block) {
5185
- const text = block.text;
5186
- return typeof text === "string" ? text : "";
5187
- }
5188
- return "";
5189
- }).join("");
5190
- }
5191
- /** Short one-line card title from selected body text. */
5192
- function previewTitle$1(text, max = 48) {
5193
- const oneLine = text.replace(/\s+/g, " ").trim();
5194
- return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
5195
- }
5196
- /** Ensure the draft carries the invisible marker, without disturbing its text. */
5197
- function withDraftMarker(draft) {
5198
- return draft.includes("​") ? draft : `${draft}​`;
5199
- }
5200
5506
  /** Remove every invisible marker from a draft. */
5201
5507
  function stripDraftMarker(draft) {
5202
5508
  return draft.split("​").join("");
5203
5509
  }
5204
- function toQuote(body) {
5205
- const text = body.join("\n").trim();
5206
- if (!text) return null;
5207
- return {
5208
- title: previewTitle$1(text),
5209
- body: text
5210
- };
5211
- }
5212
- /**
5213
- * Single-pass reader for plugin quote blocks. Yields both the parsed quotes
5214
- * and the leftover display text (question / non-quote lines).
5215
- */
5216
- function scanQuoteMessage(text) {
5217
- const quotes = [];
5218
- const restLines = [];
5219
- let body = null;
5220
- const close = () => {
5221
- if (body === null) return;
5222
- const quote = toQuote(body);
5223
- if (quote) quotes.push(quote);
5224
- body = null;
5225
- };
5226
- for (const line of text.split("\n")) {
5227
- const trimmed = line.trimEnd();
5228
- const isMarker = trimmed === "> [选中文本]" || trimmed.startsWith("> [选中文本]");
5229
- if (body === null) {
5230
- if (isMarker) body = [];
5231
- else restLines.push(line);
5232
- continue;
5233
- }
5234
- if (trimmed.startsWith(">")) {
5235
- body.push(trimmed.replace(/^>\s?/, ""));
5236
- continue;
5237
- }
5238
- close();
5239
- if (isMarker) body = [];
5240
- else restLines.push(line);
5241
- }
5242
- close();
5243
- return {
5244
- quotes,
5245
- rest: restLines.join("\n").replace(/^\n+/, "").trim()
5246
- };
5247
- }
5248
- /**
5249
- * Display text for a user bubble: drop every plugin quote block so the
5250
- * transcript shows only the question. The durable message (and model payload)
5251
- * still contains the full `> [选中文本]` text.
5252
- */
5253
- function displayTextWithoutQuote(text) {
5254
- const clean = text.replace(DRAFT_MARKER_RE, "");
5255
- if (!clean.includes("> [选中文本]")) return clean;
5256
- return scanQuoteMessage(clean).rest;
5257
- }
5258
5510
  //#endregion
5259
5511
  //#region src/client/protocol/annotation-protocol.ts
5260
5512
  /**
@@ -6096,7 +6348,9 @@ const SHAPES = {
6096
6348
  "chevron-down": "<path d=\"m6 9 6 6 6-6\"/>",
6097
6349
  "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
6098
6350
  check: "<path d=\"M20 6 9 17l-5-5\"/>",
6099
- "arrow-up-right": "<path d=\"M7 7h10v10\"/><path d=\"M7 17 17 7\"/>"
6351
+ "arrow-up-right": "<path d=\"M7 7h10v10\"/><path d=\"M7 17 17 7\"/>",
6352
+ "circle-alert": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 8v4\"/><path d=\"M12 16h.01\"/>",
6353
+ bot: "<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M15 13v2\"/><path d=\"M9 13v2\"/>"
6100
6354
  };
6101
6355
  /** Markup form for plain-DOM call sites; `insertAdjacentHTML('afterbegin', …)`. */
6102
6356
  function iconMarkup(name, size = 15) {
@@ -6759,28 +7013,37 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
6759
7013
  //#endregion
6760
7014
  //#region src/client/composer/useDraftMarker.ts
6761
7015
  /**
6762
- * Keep the composer's own send button usable while only annotations are
6763
- * pending: an invisible marker makes an otherwise empty draft non-empty.
7016
+ * True when the draft is nothing but the legacy invisible send marker.
6764
7017
  *
6765
- * Written ONCE per list change — never on a draft change (see QuoteCard history
6766
- * for why rewriting on draft fights IME / backspace).
7018
+ * The plugin used to append U+200B so an annotation-only draft looked non-empty
7019
+ * and the Host's send button stayed enabled. That is gone: it made sending a
7020
+ * wordless message possible, and a Host-rendered bubble for such a message is
7021
+ * an empty box. Older persisted drafts may still carry the character, so the
7022
+ * composer distinguishes "only the marker" (empty the draft) from "marker next
7023
+ * to real text" (strip the marker, keep the text).
6767
7024
  */
6768
- function useDraftMarker({ itemCount, draftRef, setDraft }) {
6769
- (0, react.useEffect)(() => {
6770
- if (itemCount === 0) return;
6771
- const current = draftRef.current;
6772
- if (current.includes("​")) return;
6773
- if (current.trim() !== "") return;
6774
- setDraft(withDraftMarker(current));
6775
- }, [
6776
- itemCount,
6777
- draftRef,
6778
- setDraft
6779
- ]);
7025
+ function isMarkerOnly(draft) {
7026
+ return draft.includes("​") && stripDraftMarker(draft).trim() === "";
6780
7027
  }
6781
- function clearDraftMarker(draft, setDraft) {
6782
- if (!draft.includes("​")) return;
6783
- setDraft(stripDraftMarker(draft));
7028
+ //#endregion
7029
+ //#region src/client/composer/draft-write.ts
7030
+ /**
7031
+ * Replace the draft text without clearing atomic nodes when the host supports
7032
+ * an append-only edit.
7033
+ *
7034
+ * `current` is the draft the caller read before computing `next`; on a Lexical
7035
+ * host a revision mismatch simply returns false and the caller falls back.
7036
+ */
7037
+ function writeDraft(actions, next, current) {
7038
+ if (next === current) return true;
7039
+ const capture = actions.captureInsertion;
7040
+ const insert = actions.insertText;
7041
+ if (typeof capture === "function" && typeof insert === "function") {
7042
+ const span = capture();
7043
+ if (span.start === 0 && span.end === current.length && typeof span.draftRev === "number" && insert.call(actions, next, span)) return true;
7044
+ }
7045
+ actions.setDraft(next);
7046
+ return false;
6784
7047
  }
6785
7048
  //#endregion
6786
7049
  //#region src/client/composer/useSendIntercept.ts
@@ -6789,15 +7052,16 @@ function clearDraftMarker(draft, setDraft) {
6789
7052
  * composer, or the primary send button) so the composer's submit path carries
6790
7053
  * the JSON protocol block.
6791
7054
  */
6792
- function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef }) {
7055
+ function useSendIntercept({ itemCount, draftRef, actionsRef, sessionId, onSelfWrite, onFolded, foldingRef }) {
6793
7056
  (0, react.useEffect)(() => {
6794
7057
  if (itemCount === 0) return;
6795
7058
  const inject = () => {
6796
7059
  const current = getAnnotations();
6797
7060
  if (current.length === 0) return;
6798
7061
  if (foldingRef !== void 0) foldingRef.current = true;
6799
- setDraft(composeAnnotatedMessage(draftRef.current, current));
6800
- clearAnnotations(void 0, "sent");
7062
+ onSelfWrite();
7063
+ writeDraft(actionsRef.current, composeAnnotatedMessage(draftRef.current, current), draftRef.current);
7064
+ clearAnnotations(sessionId, "sent");
6801
7065
  onFolded();
6802
7066
  };
6803
7067
  const onKeyDown = (event) => {
@@ -6829,7 +7093,9 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef
6829
7093
  }, [
6830
7094
  itemCount,
6831
7095
  draftRef,
6832
- setDraft,
7096
+ actionsRef,
7097
+ sessionId,
7098
+ onSelfWrite,
6833
7099
  onFolded,
6834
7100
  foldingRef
6835
7101
  ]);
@@ -6882,10 +7148,24 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
6882
7148
  actionsRef.current = inputActions;
6883
7149
  sessionRef.current = sessionId;
6884
7150
  const setDraft = (0, react.useCallback)((text) => {
6885
- actionsRef.current.setDraft(text);
7151
+ selfWrites.current += 1;
7152
+ writeDraft(actionsRef.current, text, draftRef.current);
6886
7153
  }, []);
7154
+ /**
7155
+ * Bumped whenever this component rewrites the draft itself.
7156
+ *
7157
+ * The rewind path below treats *any* draft carrying a complete protocol
7158
+ * block as a withdrawn message and lifts its annotations back into the
7159
+ * pending list. The host publishes every programmatic write as a normal
7160
+ * draft change, so without this counter the plugin's own write would be read
7161
+ * back as a rewind. The restore records the stamp it ran at and bails when
7162
+ * the draft it is looking at was produced by a later self-write.
7163
+ */
7164
+ const selfWrites = (0, react.useRef)(0);
7165
+ /** Stamp of the newest self-write the rewind path has already accounted for. */
7166
+ const seenSelfWrite = (0, react.useRef)(0);
6887
7167
  const sync = (0, react.useCallback)(() => {
6888
- setItems(getAnnotations());
7168
+ setItems(getAnnotations(sessionId));
6889
7169
  }, []);
6890
7170
  (0, react.useEffect)(() => {
6891
7171
  bindAnnotationSession(sessionId);
@@ -6913,15 +7193,14 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
6913
7193
  card.style.removeProperty("--dsq-quote-pad");
6914
7194
  };
6915
7195
  }, [items]);
6916
- useDraftMarker({
6917
- itemCount: items.length,
6918
- draftRef,
6919
- setDraft
6920
- });
6921
7196
  useSendIntercept({
6922
7197
  itemCount: items.length,
6923
7198
  draftRef,
6924
- setDraft,
7199
+ actionsRef,
7200
+ sessionId,
7201
+ onSelfWrite: () => {
7202
+ selfWrites.current += 1;
7203
+ },
6925
7204
  onFolded: () => setItems(EMPTY),
6926
7205
  foldingRef
6927
7206
  });
@@ -6931,9 +7210,11 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
6931
7210
  return;
6932
7211
  }
6933
7212
  if (foldingRef.current || composing.current) return;
7213
+ if (seenSelfWrite.current === selfWrites.current) return;
6934
7214
  const restored = readComposedDraft(draft);
6935
7215
  if (restored === null) return;
6936
7216
  foldingRef.current = true;
7217
+ seenSelfWrite.current = selfWrites.current;
6937
7218
  restoreAnnotations(sessionId, restored.annotations);
6938
7219
  setDraft(restored.rest);
6939
7220
  }, [
@@ -6944,41 +7225,35 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
6944
7225
  ]);
6945
7226
  (0, react.useEffect)(() => {
6946
7227
  if (composing.current) return;
6947
- if (items.length === 0) {
6948
- if (draft.includes("​")) setDraft(stripDraftMarker(draft));
6949
- return;
6950
- }
6951
- if (!draft.includes("​") && draft.trim() === "") setDraft(withDraftMarker(draft));
7228
+ if (!draft.includes("​")) return;
7229
+ setDraft(isMarkerOnly(draft) ? "" : stripDraftMarker(draft));
6952
7230
  }, [
6953
- items,
6954
7231
  draft,
6955
7232
  setDraft,
6956
7233
  composing
6957
7234
  ]);
6958
7235
  const onRemoveAll = (0, react.useCallback)(() => {
6959
- clearAnnotations(void 0, "discarded");
7236
+ clearAnnotations(sessionId, "discarded");
6960
7237
  setItems(EMPTY);
6961
- clearDraftMarker(draftRef.current, setDraft);
6962
- }, [setDraft]);
7238
+ }, []);
6963
7239
  const dropIfEmpty = (0, react.useCallback)(() => {
6964
- setItems(getAnnotations());
6965
- if (getAnnotations().length === 0) clearDraftMarker(draftRef.current, setDraft);
6966
- }, [setDraft]);
7240
+ setItems(getAnnotations(sessionId));
7241
+ }, []);
6967
7242
  const onItemSelect = (0, react.useCallback)((index, item) => {
6968
7243
  scrollToAnnotation(index + 1, item.text, item.id);
6969
7244
  }, []);
6970
7245
  const onItemRemove = (0, react.useCallback)((_index, item) => {
6971
- if (item.id) removeAnnotation(void 0, item.id);
7246
+ if (item.id) removeAnnotation(sessionId, item.id);
6972
7247
  else {
6973
- const hit = getAnnotations().find((entry) => entry.text === item.text);
6974
- if (hit) removeAnnotation(void 0, hit.id);
7248
+ const hit = getAnnotations(sessionId).find((entry) => entry.text === item.text);
7249
+ if (hit) removeAnnotation(sessionId, hit.id);
6975
7250
  }
6976
7251
  dropIfEmpty();
6977
7252
  }, [dropIfEmpty]);
6978
7253
  const onItemEdit = (0, react.useCallback)((_index, item, comment) => {
6979
- const id = item.id ?? getAnnotations().find((entry) => entry.text === item.text)?.id;
7254
+ const id = item.id ?? getAnnotations(sessionId).find((entry) => entry.text === item.text)?.id;
6980
7255
  if (!id) return;
6981
- updateAnnotationComment(void 0, id, comment);
7256
+ updateAnnotationComment(sessionId, id, comment);
6982
7257
  sync();
6983
7258
  }, [sync]);
6984
7259
  /**
@@ -6994,7 +7269,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
6994
7269
  showToast(t("handoff.archived", { title: live }));
6995
7270
  return;
6996
7271
  }
6997
- const entry = getAnnotations().find((candidate) => candidate.id === item.id);
7272
+ const entry = getAnnotations(sessionId).find((candidate) => candidate.id === item.id);
6998
7273
  const handoffId = origin.handoffId ?? entry?.origin?.handoffId;
6999
7274
  openSourceAndLocate({
7000
7275
  sessionId: origin.sessionId,
@@ -7604,20 +7879,39 @@ function readSelectionSnapshot(toolbarRoot) {
7604
7879
  function clearNativeSelection() {
7605
7880
  window.getSelection()?.removeAllRanges();
7606
7881
  }
7882
+ /**
7883
+ * Put text on the clipboard, falling back to the legacy selection copy.
7884
+ *
7885
+ * The async Clipboard API is the good path, but it is not always available in
7886
+ * practice: it rejects with `NotAllowedError` when the document is not focused
7887
+ * (a popover opened over an unfocused window, DevTools holding focus, an
7888
+ * embedded frame) and on origins the browser does not treat as secure. Only
7889
+ * reaching for the fallback when the API is *missing* turned every one of those
7890
+ * into a hard failure — the copy button reported "copy failed" and the text was
7891
+ * lost. Try the API, and on rejection fall back before giving up.
7892
+ */
7607
7893
  async function copyText(text) {
7608
- if (navigator.clipboard?.writeText) {
7894
+ if (navigator.clipboard?.writeText) try {
7609
7895
  await navigator.clipboard.writeText(text);
7610
7896
  return;
7611
- }
7897
+ } catch {}
7612
7898
  const area = document.createElement("textarea");
7613
7899
  area.value = text;
7614
7900
  area.setAttribute("readonly", "");
7615
7901
  area.style.position = "fixed";
7902
+ area.style.top = "0";
7616
7903
  area.style.left = "-9999px";
7617
7904
  document.body.appendChild(area);
7905
+ const previous = document.activeElement;
7618
7906
  area.select();
7619
- document.execCommand("copy");
7620
- area.remove();
7907
+ let copied = false;
7908
+ try {
7909
+ copied = document.execCommand("copy");
7910
+ } finally {
7911
+ area.remove();
7912
+ if (previous instanceof HTMLElement) previous.focus({ preventScroll: true });
7913
+ }
7914
+ if (!copied) throw new Error("the clipboard rejected the copy");
7621
7915
  }
7622
7916
  //#endregion
7623
7917
  //#region src/client/model/rank.ts
@@ -8407,12 +8701,9 @@ function splitLabelAt(label, accent) {
8407
8701
  { text: label.slice(at + needle.length) }
8408
8702
  ].filter((part) => part.text !== "");
8409
8703
  }
8410
- /**
8411
- * One ordered list for both surfaces: the floating toolbar paints it inline,
8412
- * the right-click menu paints it vertically. Adding or reordering an action
8413
- * here reaches both, so the two entry points cannot drift apart.
8414
- */
8415
- function selectionActions(snapshot, handoff, visibility) {
8704
+ function selectionActions(snapshot, handoff, visibility, availability) {
8705
+ const translateDisabled = availability?.translate === false;
8706
+ const sideDisabled = availability?.sideTask === false;
8416
8707
  return [
8417
8708
  ...linkTarget(snapshot.text, snapshot.anchorHref) === null ? [] : [{
8418
8709
  key: "open",
@@ -8427,7 +8718,11 @@ function selectionActions(snapshot, handoff, visibility) {
8427
8718
  ...visibility?.hideTranslate ? [] : [{
8428
8719
  key: "translate",
8429
8720
  icon: "languages",
8430
- label: t("toolbar.translate")
8721
+ label: t("toolbar.translate"),
8722
+ ...translateDisabled ? {
8723
+ disabled: true,
8724
+ disabledReason: t("toolbar.translateUnavailable")
8725
+ } : {}
8431
8726
  }],
8432
8727
  {
8433
8728
  key: "add",
@@ -8444,7 +8739,11 @@ function selectionActions(snapshot, handoff, visibility) {
8444
8739
  {
8445
8740
  key: "side",
8446
8741
  icon: "panel-right",
8447
- label: t("toolbar.askInSide")
8742
+ label: t("toolbar.askInSide"),
8743
+ ...sideDisabled ? {
8744
+ disabled: true,
8745
+ disabledReason: t("toolbar.sideUnavailable")
8746
+ } : {}
8448
8747
  }
8449
8748
  ];
8450
8749
  }
@@ -8555,9 +8854,11 @@ function SelectionMenu({ entries, x, y, onClose, initialFocus = "first" }) {
8555
8854
  role: "menuitem",
8556
8855
  className: cx(A.plain, A.row, styles.ctxItem),
8557
8856
  tabIndex: -1,
8857
+ "aria-disabled": entry.disabled ? "true" : void 0,
8858
+ "data-dsq-tip": entry.disabled ? entry.disabledReason : void 0,
8558
8859
  onPointerDown: keepSelection,
8559
8860
  onPointerEnter: (event) => event.currentTarget.focus({ preventScroll: true }),
8560
- onClick: entry.onClick,
8861
+ onClick: entry.disabled ? void 0 : entry.onClick,
8561
8862
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
8562
8863
  name: entry.icon,
8563
8864
  size: 15
@@ -8745,8 +9046,10 @@ function ActionRow({ actions, stacked, onHandoffPick }) {
8745
9046
  type: "button",
8746
9047
  className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
8747
9048
  "aria-haspopup": handoff ? "dialog" : void 0,
9049
+ "aria-disabled": action.disabled ? "true" : void 0,
9050
+ "data-dsq-tip": action.disabled ? action.disabledReason : void 0,
8748
9051
  "data-dsq-handoff-trigger": handoff ? "true" : void 0,
8749
- onClick: action.onClick,
9052
+ onClick: action.disabled ? void 0 : action.onClick,
8750
9053
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
8751
9054
  name: action.icon,
8752
9055
  size: stacked ? 18 : 14
@@ -9061,9 +9364,15 @@ function TranslatePanel({ translate, touch = false, quote }) {
9061
9364
  className: cx(A.row, styles.translateActions),
9062
9365
  children: state.status === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9063
9366
  type: "button",
9064
- className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
9367
+ className: cx(A.plain, A.gridMid, styles.translateCopy),
9368
+ "aria-label": translate.copied ? t("translate.copied") : t("translate.copy"),
9369
+ title: translate.copied ? t("translate.copied") : t("translate.copy"),
9370
+ "data-copied": translate.copied ? "true" : void 0,
9065
9371
  onClick: translate.onCopy,
9066
- children: translate.copied ? t("translate.copied") : t("translate.copy")
9372
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
9373
+ name: translate.copied ? "check" : "copy",
9374
+ size: 16
9375
+ })
9067
9376
  }) : state.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9068
9377
  type: "button",
9069
9378
  className: cx(A.plain, A.inlineRow, styles.button),
@@ -9170,11 +9479,15 @@ function ToolbarView({ touch, phase, active, status, viewport, actions, comment,
9170
9479
  })
9171
9480
  ]
9172
9481
  })] }) : null,
9173
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9482
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9174
9483
  className: styles.touchStatus,
9175
9484
  style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
9176
9485
  role: "status",
9177
- children: status
9486
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
9487
+ name: "circle-alert",
9488
+ size: 13,
9489
+ className: styles.statusIcon
9490
+ }), status]
9178
9491
  }) : null
9179
9492
  ] }), document.body);
9180
9493
  }
@@ -9246,11 +9559,15 @@ function ToolbarView({ touch, phase, active, status, viewport, actions, comment,
9246
9559
  onCancel: onCancelSide
9247
9560
  })
9248
9561
  }) : null,
9249
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9562
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9250
9563
  className: styles.status,
9251
9564
  style: toolbarStyle,
9252
9565
  role: "status",
9253
- children: status
9566
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
9567
+ name: "circle-alert",
9568
+ size: 13,
9569
+ className: styles.statusIcon
9570
+ }), status]
9254
9571
  }) : null
9255
9572
  ] }), active.layer.host);
9256
9573
  }
@@ -10072,6 +10389,24 @@ function useSelectionWatcher({ toolbarRef, commentWrapRef, phaseRef, touch, dism
10072
10389
  * its model seat and the send gesture. The transcript surface is the toolbar's;
10073
10390
  * this hook owns the question, its route and the host call.
10074
10391
  */
10392
+ /**
10393
+ * Remove the pending draft annotations that stand for this selection.
10394
+ *
10395
+ * A pending item carries every source the selection resolved to; the side send
10396
+ * carries the same locator. Matching on `selectionKeyOf` keeps two annotations
10397
+ * of identical text from different messages apart, and falls back to plain text
10398
+ * when the snapshot has no locator (the selection missed the transcript model).
10399
+ */
10400
+ function consumeQuote(target, sessionId) {
10401
+ const source = target.source;
10402
+ const wanted = selectionKeyOf(target.text, source === void 0 ? void 0 : {
10403
+ text: target.text,
10404
+ messageId: source.messageId,
10405
+ startOffset: source.startOffset,
10406
+ endOffset: source.endOffset
10407
+ });
10408
+ for (const item of getAnnotations(sessionId)) if (item.sources.some((candidate) => source === void 0 ? candidate.text === target.text : selectionKeyOf(candidate.text, candidate) === wanted)) removeAnnotation(sessionId, item.id);
10409
+ }
10075
10410
  function useSideQuestion(input) {
10076
10411
  const { phase, activeRef, hide, setStatus, setActive, dismissMenu, setPhase } = input;
10077
10412
  const [sideText, setSideText] = (0, react.useState)("");
@@ -10116,6 +10451,7 @@ function useSideQuestion(input) {
10116
10451
  run(snapshot, question, sideModel).then(() => {
10117
10452
  setSending(false);
10118
10453
  clearNativeSelection();
10454
+ consumeQuote(snapshot);
10119
10455
  hide();
10120
10456
  }, (error) => {
10121
10457
  setSending(false);
@@ -11035,7 +11371,11 @@ function SelectionToolbar({ sessionId }) {
11035
11371
  left: active.left,
11036
11372
  top: active.top
11037
11373
  };
11038
- const actions = selectionActions(active, handoff.options, visibilityFor(active.text)).map((action) => ({
11374
+ const availability = {
11375
+ sideTask: runtime.sideTask !== void 0,
11376
+ translate: runtime.translate !== void 0
11377
+ };
11378
+ const actions = selectionActions(active, handoff.options, visibilityFor(active.text), availability).map((action) => ({
11039
11379
  ...action,
11040
11380
  onClick: () => runAction(action.key, active)
11041
11381
  }));
@@ -11108,7 +11448,7 @@ function SelectionToolbar({ sessionId }) {
11108
11448
  onClose: handoff.closePicker
11109
11449
  }) : null,
11110
11450
  menu ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectionMenu, {
11111
- entries: selectionActions(menu.snapshot, handoff.options, visibilityFor(menu.snapshot.text)).map((action) => ({
11451
+ entries: selectionActions(menu.snapshot, handoff.options, visibilityFor(menu.snapshot.text), availability).map((action) => ({
11112
11452
  ...action,
11113
11453
  label: action.key === "handoff" ? action.menuLabel ?? action.label : action.label,
11114
11454
  onClick: () => runAction(action.key, menu.snapshot, "menu")
@@ -12651,11 +12991,55 @@ function bindChipPreview(chip, index) {
12651
12991
  */
12652
12992
  const DECO = "dsq-ann-directive";
12653
12993
  const DECO_KEY = "dsqAnnDirective";
12994
+ /** True when the text is exactly one directive and nothing else. */
12995
+ function isOnlyDirective(value) {
12996
+ return new RegExp(`^\\s*${ANNOTATION_DIRECTIVE_RE.source}\\s*$`, "u").test(value);
12997
+ }
12998
+ /**
12999
+ * Whether a text node sitting inside `<code>` should be turned into a chip.
13000
+ *
13001
+ * The protocol header tells the model to emit the directive inside inline code,
13002
+ * so the common case is a `<code>` whose entire content is one directive. A
13003
+ * fenced block (inside `pre`) or a sentence that merely mentions the marker
13004
+ * keeps its literal text.
13005
+ */
13006
+ function shouldChipCode(value, codeText, inPre) {
13007
+ if (inPre) return false;
13008
+ if (codeText == null) return false;
13009
+ if (codeText.trim() !== value.trim()) return false;
13010
+ return isOnlyDirective(value);
13011
+ }
13012
+ /**
13013
+ * Replace a `<code>` that holds nothing but one directive with its chip.
13014
+ *
13015
+ * The code element itself is dropped: keeping it would leave the grey inline
13016
+ * background around a button. The chip carries the same affordance the prose
13017
+ * form gets, so the reply reads the same either way.
13018
+ */
13019
+ function decorateCodeSpan(code, raw) {
13020
+ const match = new RegExp(`^\\s*${ANNOTATION_DIRECTIVE_RE.source}\\s*$`, "u").exec(raw);
13021
+ if (match === null) return;
13022
+ const index = Number(match[1]);
13023
+ if (!Number.isFinite(index) || index <= 0) return;
13024
+ const chip = document.createElement("button");
13025
+ chip.type = "button";
13026
+ chip.className = cx(styles.annDirective, A.inlineRow);
13027
+ chip.setAttribute("data-dsq-deco", "true");
13028
+ chip.dataset[DECO_KEY] = String(index);
13029
+ chip.textContent = t("chip.annotation", { index });
13030
+ bindChipPreview(chip, index);
13031
+ code.replaceWith(chip);
13032
+ }
12654
13033
  function decorateTextNode(node) {
12655
13034
  const value = node.nodeValue;
12656
13035
  if (!value || !value.includes(":dsh-annotation{")) return;
12657
13036
  const parent = node.parentElement;
12658
13037
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
13038
+ const code = parent.closest("code");
13039
+ if (code !== null && shouldChipCode(value, code.textContent, code.closest("pre") !== null)) {
13040
+ decorateCodeSpan(code, value);
13041
+ return;
13042
+ }
12659
13043
  if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
12660
13044
  if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
12661
13045
  const frag = document.createDocumentFragment();
@@ -12742,10 +13126,9 @@ function watchAnnotationDirectives(root) {
12742
13126
  * from the composer draft — which, at that instant, is the protocol block the
12743
13127
  * send gesture just folded in. A message submitted while a turn is running is
12744
13128
  * also previewed in the queue dock (`[data-queue-dock]`) from the same wire
12745
- * content. Both are rendered by the chat flow itself, not by a
12746
- * `conversation.chat.node` registration, so `UserMessageDisplay` never sees
12747
- * them; without this, the raw JSON sits on screen until (or unless) a durable
12748
- * message replaces it.
13129
+ * content. Both are painted by the chat flow itself rather than by any slot
13130
+ * entry, so the durable-message path never sees them; without this, the raw
13131
+ * JSON sits on screen until (or unless) a durable message replaces it.
12749
13132
  *
12750
13133
  * The durable message keeps the block (the model needs it). This is display
12751
13134
  * only: the echo's protocol run is rewritten in place, and the queue preview's
@@ -26754,6 +27137,57 @@ function createPluginRemote() {
26754
27137
  */
26755
27138
  var remote_default = createPluginRemote();
26756
27139
  //#endregion
27140
+ //#region src/client/transcript/chat-seat.ts
27141
+ /** Plain text from Chat user-node content blocks (string or block array). */
27142
+ function blocksToText(content) {
27143
+ if (typeof content === "string") return content;
27144
+ if (!Array.isArray(content)) return "";
27145
+ let out = "";
27146
+ for (const block of content) {
27147
+ if (block === null || typeof block !== "object") continue;
27148
+ const text = block.text;
27149
+ if (typeof text === "string") out += text;
27150
+ }
27151
+ return out;
27152
+ }
27153
+ /**
27154
+ * The message text behind one user row, or undefined while it is not loaded.
27155
+ *
27156
+ * Matching is on `rpcId`, not on `messageId`: the seat's source carries the
27157
+ * submission rpc id, and the durable node keeps that same id in its own
27158
+ * `data.source`, while `data.messageId` is a different value.
27159
+ *
27160
+ * Kept as plain data code (no React) so the verification harness can replay it
27161
+ * without a DOM.
27162
+ */
27163
+ /**
27164
+ * The message text for one chat flow key.
27165
+ *
27166
+ * The activation prompt a side question sends carries `source: { kind: "user" }`
27167
+ * with **no rpcId** (the Host's `startLocal` submits it that way), so the rpcId
27168
+ * match cannot find it. The row's own `data-chat-flow-key` is the durable key
27169
+ * into `nodes.byKey`, which resolves it exactly instead of guessing.
27170
+ */
27171
+ function chatNodeTextOf(state, flowKey) {
27172
+ if (flowKey === null || flowKey === void 0) return void 0;
27173
+ const byKey = state?.nodes?.byKey;
27174
+ if (!(byKey instanceof Map)) return void 0;
27175
+ const node = byKey.get(flowKey);
27176
+ if (node?.kind !== "user") return void 0;
27177
+ return blocksToText(node.data?.content);
27178
+ }
27179
+ function userMessageTextOf(state, rpcId) {
27180
+ if (rpcId === void 0) return void 0;
27181
+ const byKey = state?.nodes?.byKey;
27182
+ if (!(byKey instanceof Map)) return void 0;
27183
+ for (const value of byKey.values()) {
27184
+ const node = value;
27185
+ if (node?.kind !== "user") continue;
27186
+ if (node.data?.source?.rpcId !== rpcId) continue;
27187
+ return blocksToText(node.data?.content);
27188
+ }
27189
+ }
27190
+ //#endregion
26757
27191
  //#region src/client/transcript/TranscriptQuoteCard.tsx
26758
27192
  /**
26759
27193
  * History-side summary for one user message's annotations.
@@ -26765,7 +27199,7 @@ var remote_default = createPluginRemote();
26765
27199
  * scrolls back to the marked text in the source message; the ordinal badge
26766
27200
  * matches the `:dsh-annotation{index="N"}` the model refers to.
26767
27201
  */
26768
- function TranscriptQuoteCard({ node }) {
27202
+ function TranscriptQuoteCard({ node, robot = false }) {
26769
27203
  ensureToolbarStyles();
26770
27204
  const stackRef = (0, react.useRef)(null);
26771
27205
  const quotes = node.data?.quotes ?? [];
@@ -26781,7 +27215,8 @@ function TranscriptQuoteCard({ node }) {
26781
27215
  index: index + 1,
26782
27216
  text: quote.text,
26783
27217
  startOffset: quote.startOffset,
26784
- endOffset: quote.endOffset
27218
+ endOffset: quote.endOffset,
27219
+ ...robot ? { variant: "robot" } : {}
26785
27220
  })));
26786
27221
  }, [quotes]);
26787
27222
  (0, react.useEffect)(() => {
@@ -26818,6 +27253,29 @@ function TranscriptQuoteCard({ node }) {
26818
27253
  }
26819
27254
  scrollToAnnotation(index + 1, quote.text, markIdOf(index));
26820
27255
  }, [markIdOf, quotes]);
27256
+ /**
27257
+ * "From: <source>" opens the conversation this annotation was selected in.
27258
+ *
27259
+ * The composer card has always wired this; the history card rendered the same
27260
+ * button from `item.origin` but passed no handler, so the link was visibly
27261
+ * there and did nothing. A cross-session item has no mark in THIS transcript
27262
+ * by design, so the origin jump is the only way back to its source.
27263
+ */
27264
+ const onOrigin = (0, react.useCallback)((index, _item) => {
27265
+ const quote = quotes[index];
27266
+ const origin = quote?.origin;
27267
+ if (origin === void 0) return;
27268
+ const live = sessionTitleOf(origin.sessionId, origin.title);
27269
+ if (isSessionArchived(origin.sessionId)) {
27270
+ showToast(t("handoff.archived", { title: live }));
27271
+ return;
27272
+ }
27273
+ openSourceAndLocate({
27274
+ sessionId: origin.sessionId,
27275
+ text: quote.text,
27276
+ ...origin.handoffId ? { markId: handoffMarkIdOf(origin.handoffId) } : {}
27277
+ });
27278
+ }, [quotes]);
26821
27279
  if (quotes.length === 0) return null;
26822
27280
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26823
27281
  ref: stackRef,
@@ -26836,129 +27294,183 @@ function TranscriptQuoteCard({ node }) {
26836
27294
  archived: isSessionArchived(quote.origin.sessionId)
26837
27295
  } } : {}
26838
27296
  })),
26839
- onItemSelect: onSelect
27297
+ onItemSelect: onSelect,
27298
+ onItemOrigin: onOrigin
26840
27299
  })
26841
27300
  });
26842
27301
  }
26843
27302
  //#endregion
26844
- //#region src/client/transcript/UserMessageDisplay.tsx
26845
- function isTextBlock(block) {
26846
- if (block === null || typeof block !== "object") return false;
26847
- const candidate = block;
26848
- if ((candidate.type === "image" || candidate.type === "file") && candidate.attachment !== void 0) return false;
26849
- return candidate.type === "text" || typeof candidate.text === "string";
26850
- }
27303
+ //#region src/client/transcript/UserBeforeCard.tsx
26851
27304
  /**
26852
- * Replace all text blocks with one stripped text block, keeping attachments and
26853
- * unknown blocks in place. The host's `contentParts` joins text blocks and
26854
- * lifts attachments out anyway, so one text block is the same message content
26855
- * with the protocol block gone.
27305
+ * Annotation card above a user message, mounted on the Host's own decoration
27306
+ * seat (`conversation.chat.user.before`).
27307
+ *
27308
+ * dsh 0.2.2 makes this seat the official place for message-source decoration,
27309
+ * and it is the reason this plugin mounts its card here instead of overriding
27310
+ * the `user` Chat node. That override used to shadow the Host's own
27311
+ * `UserMessageNodeView`, and once shadowed the Host entry disappeared from the
27312
+ * registry — so the wrapper could never find it to delegate to, fell back to a
27313
+ * private bubble, and the message lost its time/copy row.
27314
+ *
27315
+ * The seat hands the entry `{source, kind}` only: no message body. Two ways back
27316
+ * to the body, in this order:
27317
+ *
27318
+ * 1. `source.rpcId` — a user submission or a `subagents.prompt` delivery both
27319
+ * carry one, and the durable node keeps it in `data.source`.
27320
+ * 2. the row's own `data-chat-flow-key` — the activation prompt of a side
27321
+ * question is submitted as `source: { kind: 'user' }`, with **no rpcId**.
27322
+ *
27323
+ * A subagent session is a fork of its parent: every copied message keeps the
27324
+ * parent's rpcId, so those rows would re-render the parent's cards inside the
27325
+ * child. Inherited rows are hidden there; only the child's own question (the
27326
+ * rpcId-less activation) decorates.
27327
+ */
27328
+ /**
27329
+ * Annotation summary for the user row this seat decorates.
27330
+ *
27331
+ * Renders nothing when the message carries no annotation block, so rows without
27332
+ * annotations are untouched — the Host keeps ownership of the bubble, its
27333
+ * attachments and its time/copy actions row.
26856
27334
  */
26857
- function strippedContent(raw, text) {
26858
- const rest = Array.isArray(raw) ? raw.filter((block) => !isTextBlock(block)) : [];
26859
- if (text) rest.unshift({
26860
- type: "text",
26861
- text
27335
+ function UserBeforeCard({ matched, useChat, sessionId }) {
27336
+ const rpcId = typeof matched?.source?.rpcId === "string" ? matched.source.rpcId : void 0;
27337
+ const hostRef = (0, react.useRef)(null);
27338
+ const [flowKey, setFlowKey] = (0, react.useState)(null);
27339
+ (0, react.useLayoutEffect)(() => {
27340
+ if (rpcId !== void 0) return;
27341
+ const key = hostRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
27342
+ setFlowKey(key ?? null);
27343
+ }, [rpcId]);
27344
+ const select = (read) => typeof useChat === "function" ? useChat(read) : void 0;
27345
+ const rpcText = select((state) => userMessageTextOf(state, rpcId));
27346
+ const keyedText = select((state) => chatNodeTextOf(state, flowKey));
27347
+ const text = rpcText ?? keyedText;
27348
+ const quotes = (0, react.useMemo)(() => {
27349
+ if (typeof text !== "string" || !text.includes("<response-annotations>")) return null;
27350
+ const scanned = scanAnnotatedMessage(text);
27351
+ return scanned.annotations.length > 0 ? scanned.annotations : null;
27352
+ }, [text]);
27353
+ const isSubagent = sessionId !== void 0 && runtime.isSubagentSession?.(sessionId) === true;
27354
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27355
+ ref: hostRef,
27356
+ style: { display: "contents" },
27357
+ "data-dsq-user-before": "",
27358
+ children: quotes !== null && !(isSubagent && rpcId !== void 0) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, {
27359
+ node: { data: { quotes } },
27360
+ robot: isSubagent
27361
+ }) : null
26862
27362
  });
26863
- return rest;
26864
- }
26865
- /** Image attachments for the factory-less fallback bubble. */
26866
- function imageAttachments(raw) {
26867
- if (!Array.isArray(raw)) return [];
26868
- const images = [];
26869
- for (const block of raw) {
26870
- if (block === null || typeof block !== "object") continue;
26871
- const candidate = block;
26872
- if (candidate.type === "image" && candidate.attachment !== void 0) images.push(candidate.attachment);
26873
- }
26874
- return images;
26875
27363
  }
27364
+ //#endregion
27365
+ //#region src/client/transcript/protocol-rule.ts
26876
27366
  /**
26877
- * Replacement for the built-in `user` Chat node view.
27367
+ * dsh 0.2.2 user-text rule: hide the annotation protocol block.
27368
+ *
27369
+ * The host splits a durable user message into inline nodes through
27370
+ * `uiConversation.userText` rules. Text that no rule claims is rendered as the
27371
+ * literal substring, so a message carrying this plugin's
27372
+ * `<response-annotations>` block would paint the whole JSON — header prose
27373
+ * included — into the bubble.
26878
27374
  *
26879
- * It does **not** redraw the bubble or its actions row. It resolves the host's
26880
- * own `UserMessageNodeView` from `ctx.slots.entries('conversation.chat.node')`
26881
- * and renders that component with a rewritten `node`: the annotation protocol
26882
- * block is removed from the text blocks, everything else (attachments, file
26883
- * cards, reference labels, time, copy, and any plugin that portals into the
26884
- * actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
26885
- * The annotation summary is the only extra DOM, prepended above the host row.
27375
+ * The rule claims exactly the protocol span (header prose and the
27376
+ * `<response-annotations>…</response-annotations>` payload) and renders it as
27377
+ * nothing. Annotation cards themselves are not built here: this rule only
27378
+ * removes text. The cards come from the `conversation.chat.user.before` chain,
27379
+ * which the host still renders above the bubble.
26886
27380
  *
26887
- * The summary must live inside the `user` node view rather than on a
26888
- * `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
26889
- * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
26890
- * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
26891
- * other kind is `hidden` when the completed turn's process disclosure folds.
27381
+ * Contract (see `UserTextDocument` in `@deepseek-ai/dsh-client-ui-conversation`):
27382
+ * `match(text, from, context)` must return a node whose `start >= from`, whose
27383
+ * `end > start`, whose `end <= text.length`, and whose `id` is either this
27384
+ * rule's id or the reserved `text` id. Returning `undefined` means "nothing
27385
+ * here". Evaluators run the head of every rule at each cursor, so the rule must
27386
+ * be cheap and must report the earliest claimed position, not the first
27387
+ * occurrence in the document.
26892
27388
  */
26893
- function UserMessageDisplay(props) {
26894
- ensureToolbarStyles();
26895
- const { node, hostNodeOf, ...rest } = props;
26896
- const raw = contentBlocksToText(node.data?.content);
26897
- const scanned = scanAnnotatedMessage(raw);
26898
- const text = displayTextWithoutQuote(scanned.rest).trim();
26899
- const quotes = scanned.annotations;
26900
- const rewritten = quotes.length > 0 || text !== raw.trim();
26901
- const displayNode = (0, react.useMemo)(() => rewritten ? {
26902
- ...node,
26903
- data: {
26904
- ...node.data,
26905
- content: strippedContent(node.data?.content, text)
26906
- }
26907
- } : node, [
26908
- node,
26909
- rewritten,
26910
- text
26911
- ]);
26912
- const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
26913
- const Host = hostNodeOf?.();
26914
- if (Host !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Host, {
26915
- ...rest,
26916
- node: displayNode
26917
- })] });
26918
- const images = imageAttachments(node.data?.content);
26919
- const loadImage = props.loadImage;
26920
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26921
- className: cx(A.colEnd, styles.userRow),
26922
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26923
- className: cx(A.colEnd, styles.userStack),
26924
- "data-dsq-fallback-content": "true",
26925
- children: [images.length > 0 && loadImage !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26926
- className: cx(A.colEnd, styles.userImages),
26927
- "data-message-attachments": true,
26928
- children: images.map((attachment, index) => {
26929
- const url = loadImage(attachment);
26930
- return url === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
26931
- src: url,
26932
- alt: ""
26933
- }, `image-${index}`);
26934
- })
26935
- }) : null, text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26936
- className: styles.userBubble,
26937
- children: text
26938
- }) : null]
26939
- })
26940
- })] });
26941
- }
26942
- //#endregion
26943
- //#region src/client/transcript/host-user.ts
26944
- const HOST_NODE_NAME = "UserMessageNodeView";
26945
- /** Component name across memo/forwardRef/plain function shapes. */
26946
- function componentName(component) {
26947
- const candidate = component;
26948
- return candidate?.type?.name ?? candidate?.render?.name ?? candidate?.name ?? candidate?.displayName;
27389
+ /** Rule id registered with `uiConversation.userText`. */
27390
+ const PROTOCOL_RULE_ID = "select-quote.annotations";
27391
+ /**
27392
+ * Runs after the host's own rules (`session` = 10, whitespace = 1000/1010) so
27393
+ * a `@session` reference inside the block cannot claim a prefix of it and leave
27394
+ * a half-block behind. Lower order wins ties at the same start.
27395
+ */
27396
+ const PROTOCOL_RULE_ORDER = 10100;
27397
+ const BLOCK_OPEN = "<response-annotations>";
27398
+ const BLOCK_CLOSE = "</response-annotations>";
27399
+ const HEADER_MARKER = "# Response annotations:";
27400
+ /**
27401
+ * True when `between` is the instruction body that sits between the header
27402
+ * title and the open marker: prose lines followed by one blank line, with no
27403
+ * second paragraph break. An unrelated quotation of the title leaves a whole
27404
+ * paragraph of other text there instead, and is rejected.
27405
+ */
27406
+ function isProtocolHeader(between) {
27407
+ const trimmed = between.replace(/\n+$/u, "");
27408
+ if (trimmed === "") return false;
27409
+ return !trimmed.includes("\n\n");
26949
27410
  }
26950
27411
  /**
26951
- * The host view registered for `key`, or undefined when it cannot be
26952
- * identified safely.
27412
+ * The protocol span that begins at or after `from`, or undefined.
26953
27413
  *
26954
- * A renamed host leaves one non-self entry for that key, which is still
26955
- * almost certainly the host; more than one is ambiguous, so callers should
26956
- * show the hostless fallback instead of delegating to an arbitrary plugin's
26957
- * wrapper.
27414
+ * The close marker may itself appear inside a JSON string of an older message
27415
+ * (written before `<` was escaped), so the scan accepts the first close marker
27416
+ * that leaves a JSON payload the composer's own reader would accept. A block
27417
+ * whose payload is unreadable is still hidden as long as both markers are
27418
+ * present — protocol text must never reach the transcript, valid or not.
26958
27419
  */
26959
- function pickHostNode(entries, self, key) {
26960
- const candidates = entries.filter((entry) => entry.options?.key === key && entry.component !== void 0 && entry.component !== self);
26961
- return (candidates.find((entry) => componentName(entry.component) === HOST_NODE_NAME) ?? (candidates.length === 1 ? candidates[0] : void 0))?.component;
27420
+ function nextProtocolSpan(text, from) {
27421
+ const open = text.indexOf(BLOCK_OPEN, from);
27422
+ if (open < 0) return void 0;
27423
+ const payloadStart = open + 22;
27424
+ let close = text.indexOf(BLOCK_CLOSE, payloadStart);
27425
+ let end = -1;
27426
+ while (close >= 0) {
27427
+ const payload = text.slice(payloadStart, close).trim().replace(/\\u003c/gu, "<");
27428
+ try {
27429
+ JSON.parse(payload);
27430
+ end = close + 23;
27431
+ break;
27432
+ } catch {
27433
+ close = text.indexOf(BLOCK_CLOSE, close + 23);
27434
+ }
27435
+ }
27436
+ if (end < 0) {
27437
+ const firstClose = text.indexOf(BLOCK_CLOSE, payloadStart);
27438
+ if (firstClose < 0) return void 0;
27439
+ end = firstClose + 23;
27440
+ }
27441
+ const header = text.lastIndexOf(HEADER_MARKER, open);
27442
+ let start = open;
27443
+ if (header >= from && isProtocolHeader(text.slice(header + 23, open))) start = header;
27444
+ let trimmed = end;
27445
+ while (trimmed < text.length && (text[trimmed] === "\n" || text[trimmed] === "\r")) trimmed += 1;
27446
+ return {
27447
+ start,
27448
+ end: trimmed
27449
+ };
27450
+ }
27451
+ /**
27452
+ * Build the rule. `data.text` must equal the claimed substring byte for byte —
27453
+ * the host validates `text` nodes against `text.slice(start, end)`; `display`
27454
+ * is what actually renders, and an empty display hides the block.
27455
+ */
27456
+ function createProtocolRule() {
27457
+ return {
27458
+ id: PROTOCOL_RULE_ID,
27459
+ order: PROTOCOL_RULE_ORDER,
27460
+ match(text, from) {
27461
+ const span = nextProtocolSpan(text, from);
27462
+ if (span === void 0) return void 0;
27463
+ return {
27464
+ id: "text",
27465
+ start: span.start,
27466
+ end: span.end,
27467
+ data: {
27468
+ text: text.slice(span.start, span.end),
27469
+ display: ""
27470
+ }
27471
+ };
27472
+ }
27473
+ };
26962
27474
  }
26963
27475
  //#endregion
26964
27476
  //#region src/client/state/annotation-persistence.ts
@@ -27223,13 +27735,13 @@ function createAnnotationPersistence() {
27223
27735
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
27224
27736
  * are always observed. Annotation summary stays on the session composer.
27225
27737
  *
27226
- * History summary renders **inside** the `user` Chat node view, not on a
27227
- * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
27228
- * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
27229
- * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
27230
- * kind is classified as a turn-process member and `hidden` when the completed
27231
- * turn's process disclosure is folded — mounted, but invisible. `user` is on
27232
- * that allowlist, so the bubble is the only placement that is reliably visible.
27738
+ * History summary mounts on the Host's own decoration seat
27739
+ * (`conversation.chat.user.before`) rather than by overriding a Chat node:
27740
+ * overriding the `user` node shadowed the Host's `UserMessageNodeView`, which
27741
+ * then left the registry and could not be delegated to — the message lost its
27742
+ * time/copy actions row. The seat keeps the bubble Host-owned; the card only
27743
+ * reads the message body through the `useChat` snapshot (see
27744
+ * `transcript/UserBeforeCard.tsx`).
27233
27745
  *
27234
27746
  * Language is the host's decision, never this plugin's: dictionaries go to
27235
27747
  * `ctx.locale`, and `t()` resolves against whatever language the user picked in
@@ -27246,6 +27758,35 @@ function createAnnotationPersistence() {
27246
27758
  const inject = ["slots", "locale"];
27247
27759
  function apply(ctx) {
27248
27760
  ensureToolbarStyles();
27761
+ try {
27762
+ applyBody(ctx);
27763
+ } catch (error) {
27764
+ console.error("dsh-select-quote: activation failed", error);
27765
+ throw error;
27766
+ }
27767
+ }
27768
+ /**
27769
+ * Annotation cards hang on the Host's own user-message decoration seat.
27770
+ *
27771
+ * `conversation.chat.user.before` is the official place for message-source
27772
+ * decoration in dsh 0.2.2, and using it is what keeps the bubble Host-owned:
27773
+ * the message keeps its attachments, reference summary and time/copy actions
27774
+ * row, and this plugin only adds the summary above it.
27775
+ *
27776
+ * The entry receives `{source, kind}` — no message body — so the card resolves
27777
+ * the body through the `useChat` seat (see `transcript/UserBeforeCard.tsx`),
27778
+ * matching the durable node by `source.rpcId`.
27779
+ */
27780
+ function registerHistoryCard(ctx) {
27781
+ ctx.slots.inject("conversation.chat.user.before", () => ctx.slots.register({
27782
+ name: "conversation.chat.user.before",
27783
+ select: (owner) => {
27784
+ return owner?.kind === "user" ? owner : null;
27785
+ }
27786
+ }, UserBeforeCard));
27787
+ }
27788
+ function applyBody(ctx) {
27789
+ registerHistoryCard(ctx);
27249
27790
  const hostLanguageIds = new Set(ctx.locale.getSnapshot().locales.map((language) => language.id.toLowerCase()));
27250
27791
  for (const language of OPTIONAL_LANGUAGES) {
27251
27792
  if (hostLanguageIds.has(language.id)) continue;
@@ -27359,11 +27900,12 @@ function apply(ctx) {
27359
27900
  let publishedTranslate;
27360
27901
  const publish = () => {
27361
27902
  const api = scope.get("remote.selectQuoteSideTask");
27362
- const sidebar = scope.get("sidebarRight");
27363
- if (api !== void 0 && sidebar !== void 0) {
27903
+ if (api !== void 0) {
27364
27904
  const openSideTask = async (snapshot, question, model) => {
27365
27905
  const sessionId = runtime.sessionId;
27366
27906
  if (!sessionId) throw new Error("no active session");
27907
+ const sidebar = scope.get("sidebarRight");
27908
+ if (sidebar === void 0) throw new Error("the right sidebar is unavailable");
27367
27909
  const source = snapshot.source;
27368
27910
  const prompt = composeQuotedMessage(question, {
27369
27911
  text: snapshot.text,
@@ -27465,6 +28007,13 @@ function apply(ctx) {
27465
28007
  });
27466
28008
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
27467
28009
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
28010
+ ctx.effect(() => {
28011
+ if (typeof document === "undefined" || typeof window === "undefined") return () => {};
28012
+ const relayout = () => relayoutAnnotationMarks();
28013
+ window.addEventListener("resize", relayout);
28014
+ document.fonts?.ready.then(relayout).catch(() => {});
28015
+ return () => window.removeEventListener("resize", relayout);
28016
+ }, "dsh-select-quote: mark badge relayout");
27468
28017
  ctx.effect(() => bindAnnotationMarkPreview(), "dsh-select-quote: mark preview");
27469
28018
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
27470
28019
  ctx.effect(() => {
@@ -27495,6 +28044,11 @@ function apply(ctx) {
27495
28044
  setAnnotationPersistence(null);
27496
28045
  };
27497
28046
  }, "dsh-select-quote: annotation persistence");
28047
+ ctx.inject(["uiConversation"], (scope) => {
28048
+ const rules = scope.uiConversation?.userText;
28049
+ if (typeof rules?.register !== "function") return;
28050
+ scope.effect(() => rules.register(createProtocolRule()), "dsh-select-quote: user-text rule");
28051
+ });
27498
28052
  ctx.slots.inject("shell.overlay", () => {
27499
28053
  ctx.slots.register({
27500
28054
  name: "shell.overlay",
@@ -27516,29 +28070,6 @@ function apply(ctx) {
27516
28070
  order: 1
27517
28071
  }, SessionBridge);
27518
28072
  });
27519
- const hostNodeOf = (key, self) => {
27520
- return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
27521
- };
27522
- function UserNode(props) {
27523
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
27524
- ...props,
27525
- hostNodeOf: () => hostNodeOf("user", UserNode)
27526
- });
27527
- }
27528
- function SteeringNode(props) {
27529
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
27530
- ...props,
27531
- hostNodeOf: () => hostNodeOf("steering", SteeringNode)
27532
- });
27533
- }
27534
- for (const [key, View] of [["user", UserNode], ["steering", SteeringNode]]) ctx.slots.inject("conversation.chat.node", () => {
27535
- ctx.slots.register({
27536
- name: "conversation.chat.node",
27537
- key,
27538
- priority: -10,
27539
- locale: "chat"
27540
- }, View);
27541
- });
27542
28073
  }
27543
28074
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
27544
28075
  function SessionBridge({ sessionId }) {