dsh-select-quote 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -30
  2. package/lib/client.js +476 -131
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # dsh-select-quote
2
2
 
3
- [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harness/guide/quickstart) 的 Web 客户端插件:在对话里**划词加批注**——把选中的原文连同你的评论一起交给模型,并让它在回答里明确指出说的是哪一段。
3
+ [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harness/guide/quickstart) 的客户端插件:在对话里**划词加批注**——选中的原文连同你的评论一起交给模型,并让它在回答里明确指出说的是哪一段。
4
4
 
5
5
  - **划词工具条**:选中对话区任意文本,浮出「复制 / 添加到任务」;拖拽结束才出现,不挡操作
6
- - **批注卡片**:添加后输入框内出现一张摘要卡片(可多条),**草稿里不会出现任何协议文本**
7
- - **正文标记**:被划中的文字在原消息里留下虚线下划线 + 序号角标;点卡片里的条目会滚回那段文字
8
- - **模型回指**:模型回答时以内联指令标出它处理的每一条批注,插件把这些指令渲染成可点击的 chip
9
- - **对话记录**:已发送消息里的批注渲染为卡片,用户气泡只剩你打的问题
6
+ - **批注卡片**:添加后输入框内出现摘要卡片(可多条),草稿里不会出现任何协议文本
7
+ - **正文标记**:被划中的文字在原消息里留下虚线下划线 + 序号角标,点卡片条目会滚回那段文字
8
+ - **模型回指**:模型回答里指向某条批注的位置,渲染成可点击的 chip
9
+ - **对话记录**:已发送消息里的批注渲染成卡片,用户气泡只剩你打的问题
10
+ - **界面语言**:跟随 dsh 自己的语言设置,简体中文 / English / Русский
10
11
 
11
12
  ![screenshot](https://raw.githubusercontent.com/latte03/dsh-select-quote/main/assets/screenshot-1.png)
12
13
 
@@ -14,59 +15,85 @@
14
15
 
15
16
  ---
16
17
 
17
- ## 兼容性
18
-
19
- | 项 | 值 |
20
- |---|---|
21
- | dsh | `0.1.7-rc.1`(2026-09-24 实测:划词、批注卡片、正文标记、对话记录渲染全通) |
22
- | profile | web(`dsh web`) |
23
- | 运行时 | 只有浏览器半有业务;Node 半仅用于让包被 Loader 挂载 |
24
-
25
- 实现依赖若干产品内部约定(槽位名、composer DOM、深色主题属性等),升级 dsh 后请先跑 `npm run check`,再照 [`docs/internals.md`](docs/internals.md) 末尾的复核清单人工回归一遍。
26
-
27
18
  ## 安装
28
19
 
29
- ### 方式一:从 npm 安装(bundle)
20
+ 网页版:
30
21
 
31
22
  ```bash
32
23
  dsh plugin --profile web add dsh-select-quote
33
24
  dsh --profile web
34
25
  ```
35
26
 
36
- > ⚠️ npm 上目前只有 `0.1.0`,那是旧版「引用块」行为,不含批注卡片、正文标记与模型回指。要用现在这套,请先用方式二,或从仓库目录安装:`dsh plugin --profile web add <本仓库绝对路径>`。
27
+ 桌面版:在应用内输入`dsh-select-quote`安装,或者插件页搜索 `dsh-select-quote` 安装,重启客户端生效。
37
28
 
38
- ### 方式二:本地 checkout 加载(`--patch`)
29
+ 改了代码想立刻验证,用本地 checkout 加载:
39
30
 
40
31
  ```bash
41
- git clone <repo> && cd dsh-select-quote
32
+ git clone https://github.com/latte03/dsh-select-quote && cd dsh-select-quote
42
33
  npm install && npm run build
43
34
  dsh web --patch "$PWD/dev.patch.yml"
44
35
  ```
45
36
 
46
- 打开 `http://127.0.0.1:3080`,在对话消息里划词即可看到工具条。`dev.patch.yml` 里的插件路径按本机写死(必须是绝对路径),换机器或换目录时改那一行;该文件不随 npm 包发布。
37
+ 打开 `http://127.0.0.1:3080`,在对话消息里划词即可看到工具条。`dev.patch.yml` 里的插件路径必须是绝对路径,换机器或换目录时改那一行;该文件不随 npm 包发布。
47
38
 
48
39
  ## 用法
49
40
 
50
- 1. 在对话区选中一段文本 → 点「添加到任务」
51
- 2. 弹出评论框,写一句想说的话(可留空,只划不评)→ `⌘/Ctrl + Enter` 确认
52
- 3. 输入框上方出现批注卡片,悬停可展开列表:逐条编辑、删除,或一键清空;点条目会跳到正文里对应的那段文字
53
- 4. 正常打字、按 Enter 发送。批注随消息一起交给模型,卡片随之消失
54
- 5. 模型回答里指向某条批注的位置,会显示成可点的 chip
41
+ 1. 在对话区选中一段文本,点「添加到任务」
42
+ 2. 在弹出的评论框里写一句想说的话(可留空,只划不评),`⌘/Ctrl + Enter` 确认
43
+ 3. 输入框上方出现批注卡片:悬停展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字
44
+ 4. 正常打字发送,批注随消息一起交给模型,卡片随之消失
45
+ 5. 模型回答里引用某条批注的位置显示为可点击 chip
46
+
47
+ ## 语言
48
+
49
+ 界面语言由 dsh 决定,插件不再自己判断浏览器语言。在 **设置 → 通用 → 语言** 里切换。
50
+
55
51
 
56
52
  ## 已知限制
57
53
 
58
- - **待发批注只在内存里**:刷新页面或切换会话后会丢,卡片不持久化(已发送消息里的批注不受影响)。
59
- - **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user` 节点。若其它插件也替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),会被本插件屏蔽。
54
+ - **待发批注只在内存里**:刷新页面或切换会话后会丢,卡片不持久化;已发送消息里的批注不受影响。
55
+ - **占用了用户气泡的渲染**:本插件以最低优先级替换内置 `user` 节点。其它插件若替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),会被本插件屏蔽。
60
56
  - **卡片宽度有限**:输入框内每条摘要最宽约为输入框的 1/4,超长标题走省略号。
61
- - 划词只在对话区生效,不介入编辑器、终端等面板。
57
+ - **划词只在对话区生效**:不介入编辑器、终端等面板。
58
+
59
+ ## 常见问题
60
+
61
+ **升级 dsh 之后功能不对?** 先 `npm run check`;产物没问题就对照 [`docs/internals.md`](docs/internals.md) 的「依赖的产品内部约定」逐条复核,那里列了所有产品内部名。
62
+
63
+ **划词没有工具条?** 确认选区在对话区而不是编辑器里;刷新页面(样式与观察者是幂等注入的);宿主 boot 日志里应出现 `dsh-select-quote`。
64
+
65
+ **输入框里出现了协议原文?** 发送前的注入没生效,一般是 composer 结构变了——检查 `[data-composer-card]` 是否还在。
66
+
67
+ **对话记录里没有批注卡片?** 卡片渲染在用户气泡内部,若 `user` 节点被别的接管者赢走,或那条消息里确实没有批注块,都不会出现。
68
+
69
+ **界面语言不跟着 dsh 变?** 文案取自宿主的语言服务;宿主没有该服务或语言未注册时回退到 English。此时确认 dsh 版本带 `dsh-client-locale`,以及设置里确有语言选项。
70
+
71
+ **改了 CSS 不生效?** 样式只注入一次,刷新页面。
62
72
 
63
73
  ## 开发
64
74
 
65
75
  ```bash
66
- npm run check # typecheck + build + verify,提交前跑这个
76
+ npm install
77
+ npm run check # typecheck + build + verify
78
+ npm run watch # 增量重建
79
+ ```
80
+
81
+ `npm run verify` 无头执行产物,断言 8 项贡献都注册上,并检查三份词典的键与 `{placeholder}` 在每种语言里都齐备(复数消息必须有 `.other` 兜底)。
82
+
83
+ 发布:先把源码与产物 `lib/` 一起提交,再跑
84
+
85
+ ```bash
86
+ npm run release # check → 追加 CHANGELOG.md → 提升版本号并打 tag
67
87
  ```
68
88
 
69
- 改 CSS 后要刷新页面(样式只注入一次)。协议格式、槽位注册、以及几处非显而易见的坑(U+200B 草稿标记、portal 到 body 的浮层、`--dsq-quote-pad` 高度补偿)记录在 **[docs/internals.md](docs/internals.md)**。
89
+ ## 贡献
90
+
91
+ fork 后开分支,提交前跑 `npm run check`。一个 PR 只讲一件事,功能与文档分开,方便追溯。PR 里写清复现方式(环境、dsh 版本、操作步骤)。
92
+
93
+ ## 致谢
94
+
95
+ - [@imallakov](https://github.com/imallakov):多语言文案、跨平台构建、装饰器楔死修复与批注卡片归属(PR #2 / #3 / #4)
96
+ - [@EliteOtaku](https://github.com/EliteOtaku):桌面端渲染进程楔死的定位报告与 CDP 探针方法([#1](https://github.com/latte03/dsh-select-quote/issues/1))
70
97
 
71
98
  ## License
72
99
 
package/lib/client.js CHANGED
@@ -642,17 +642,19 @@ const CSS = `
642
642
  gap: 6px;
643
643
  }
644
644
 
645
- /* Transcript cards — in-flow Chat node (conversation history), stacked. */
645
+ /* Transcript cards — in-flow rows under the user bubble, side by side. */
646
646
  .dsq_tCardStack {
647
647
  display: flex;
648
- flex-direction: column;
648
+ flex-direction: row;
649
+ flex-wrap: wrap;
650
+ justify-content: flex-end;
649
651
  align-items: stretch;
650
652
  gap: 6px;
651
653
  margin: 4px 0 8px;
652
- /* Match composer summary width; sit on the user-bubble side (right). */
653
- width: 20%;
654
- min-width: 148px;
655
- max-width: 220px;
654
+ /* Cards lay out horizontally and wrap onto the next row when they run out of
655
+ width. Each card carries the selected text plus the user's comment, so they
656
+ stay compact and stay on the user-bubble side (right). */
657
+ max-width: 100%;
656
658
  margin-left: auto;
657
659
  margin-right: 0;
658
660
  }
@@ -729,6 +731,77 @@ const CSS = `
729
731
  white-space: nowrap;
730
732
  }
731
733
 
734
+ /* Transcript card wrapper: the card itself is a click target that scrolls back
735
+ to the marked text. Unlike the composer summary (one collapsed row per
736
+ message) each history card shows its own comment, so the fixed 64px height
737
+ and the single-line clamps are lifted here. */
738
+ .dsq_tCardButton {
739
+ appearance: none;
740
+ display: block;
741
+ /* Compact cards: several fit in one row before wrapping. */
742
+ flex: 0 1 auto;
743
+ width: 230px;
744
+ max-width: 100%;
745
+ padding: 0;
746
+ border: none;
747
+ background: transparent;
748
+ font: inherit;
749
+ text-align: left;
750
+ cursor: pointer;
751
+ border-radius: 14px;
752
+ }
753
+
754
+ .dsq_tCardButton:focus-visible {
755
+ outline: 2px solid var(--dsw-static-blue-500, #3b82f6);
756
+ outline-offset: 2px;
757
+ }
758
+
759
+ .dsq_tCardButton .dsq_tCard {
760
+ height: auto;
761
+ min-height: 0;
762
+ align-items: flex-start;
763
+ width: 100%;
764
+ gap: 8px;
765
+ padding: 8px;
766
+ border-radius: 14px;
767
+ }
768
+
769
+ .dsq_tCardButton .dsq_tCardIcon {
770
+ width: 22px;
771
+ height: 22px;
772
+ border-radius: 8px;
773
+ font-size: 11px;
774
+ }
775
+
776
+ .dsq_tCardButton .dsq_tCardTitle {
777
+ display: -webkit-box;
778
+ -webkit-line-clamp: 2;
779
+ -webkit-box-orient: vertical;
780
+ overflow: hidden;
781
+ white-space: normal;
782
+ overflow-wrap: anywhere;
783
+ font-size: 13px;
784
+ line-height: 18px;
785
+ }
786
+
787
+ .dsq_tCardButton .dsq_tCardSubtitle {
788
+ display: -webkit-box;
789
+ -webkit-line-clamp: 3;
790
+ -webkit-box-orient: vertical;
791
+ overflow: hidden;
792
+ white-space: pre-wrap;
793
+ overflow-wrap: anywhere;
794
+ margin-top: 2px;
795
+ font-size: 12px;
796
+ line-height: 16px;
797
+ }
798
+
799
+ /* A comment is the user's own words — give it more presence than the neutral
800
+ "no comment" placeholder. */
801
+ .dsq_tCardButton .dsq_tCardSubtitle[data-dsq-has-comment='true'] {
802
+ color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
803
+ }
804
+
732
805
  /* Replacement user bubble (quote block stripped from display). */
733
806
  .dsq_userRow {
734
807
  display: flex;
@@ -916,6 +989,7 @@ const styles = {
916
989
  commentInput: "dsq_commentInput",
917
990
  commentActions: "dsq_commentActions",
918
991
  tCardStack: "dsq_tCardStack",
992
+ tCardButton: "dsq_tCardButton",
919
993
  tCard: "dsq_tCard",
920
994
  tCardIcon: "dsq_tCardIcon",
921
995
  tCardBody: "dsq_tCardBody",
@@ -1338,16 +1412,6 @@ function contentBlocksToText(content) {
1338
1412
  return "";
1339
1413
  }).join("");
1340
1414
  }
1341
- /**
1342
- * Best-effort plain text from a durable `user/message` event. A surface event
1343
- * carries its blocks directly on `data.content`; `data.message.content` is only
1344
- * a fallback for the older shape.
1345
- */
1346
- function extractUserText(event) {
1347
- const data = event?.data;
1348
- if (!data) return "";
1349
- return contentBlocksToText(data.content ?? data.message?.content);
1350
- }
1351
1415
  /** Short one-line card title from selected body text. */
1352
1416
  function previewTitle$1(text, max = 48) {
1353
1417
  const oneLine = text.replace(/\s+/g, " ").trim();
@@ -1526,10 +1590,6 @@ function scanAnnotatedMessage(text) {
1526
1590
  rest
1527
1591
  };
1528
1592
  }
1529
- /** Convenience: annotations only. */
1530
- function parseAnnotatedMessage(text) {
1531
- return scanAnnotatedMessage(text).annotations;
1532
- }
1533
1593
  //#endregion
1534
1594
  //#region src/client/state/annotation-store.ts
1535
1595
  /** Per-session pending selection annotations (composer-side store). */
@@ -1704,7 +1764,249 @@ function LucideIcon({ name, size = 15 }) {
1704
1764
  });
1705
1765
  }
1706
1766
  //#endregion
1767
+ //#region src/client/i18n.ts
1768
+ /**
1769
+ * Copy for dsh-select-quote, served through the host locale service.
1770
+ *
1771
+ * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
1772
+ * General → Language, persisted as `locale.preference`). This module owns only
1773
+ * the dictionaries and the plural form selection that the host's plain-template
1774
+ * lookup cannot express; it never resolves a language on its own.
1775
+ *
1776
+ * NOT localized on purpose:
1777
+ * - wire markers (`> [选中文本]`, `<response-annotations>`,
1778
+ * `:dsh-annotation{index="N"}`) — they are the message format the model
1779
+ * and the transcript parser agree on;
1780
+ * - the `# Response annotations:` header — model-facing instruction prose,
1781
+ * not UI text.
1782
+ */
1783
+ const LOCALE_NS = "select-quote";
1784
+ const zh = {
1785
+ "common.selectedText": "选中的文本",
1786
+ "common.commentPlaceholder": "添加可选评论…",
1787
+ "common.cancel": "取消",
1788
+ "common.save": "保存",
1789
+ "toolbar.aria": "划词操作",
1790
+ "toolbar.commentHint": "添加批注…",
1791
+ "toolbar.copy": "复制",
1792
+ "toolbar.copyFailed": "复制失败",
1793
+ "toolbar.addToTask": "添加到任务",
1794
+ "toolbar.commentDialogAria": "添加可选评论",
1795
+ "toolbar.skipComment": "暂不评论",
1796
+ "toolbar.addComment": "添加评论",
1797
+ "card.commentSubtitle": "评论 · {comment}",
1798
+ "card.ariaIndexed": "批注 {index}",
1799
+ "card.ariaPlain": "划词批注",
1800
+ "card.editComment": "编辑评论",
1801
+ "card.remove": "移除批注",
1802
+ "summary.hint": "悬停查看批注",
1803
+ "summary.dialogAria": "{count} 条划词批注详情",
1804
+ "summary.edit": "编辑批注 {index}",
1805
+ "summary.remove": "移除批注 {index}",
1806
+ "summary.userComment": "用户评论",
1807
+ "summary.noComment": "未添加评论",
1808
+ "summary.aria.other": "{count} 条划词批注。悬停查看详情。",
1809
+ "summary.title.other": "{count} 条批注",
1810
+ "summary.removeAll": "移除全部划词批注",
1811
+ "chip.annotation": "批注 {index}"
1812
+ };
1813
+ const en = {
1814
+ "common.selectedText": "Selected text",
1815
+ "common.commentPlaceholder": "Add an optional comment…",
1816
+ "common.cancel": "Cancel",
1817
+ "common.save": "Save",
1818
+ "toolbar.aria": "Selection actions",
1819
+ "toolbar.commentHint": "Add annotation…",
1820
+ "toolbar.copy": "Copy",
1821
+ "toolbar.copyFailed": "Copy failed",
1822
+ "toolbar.addToTask": "Add to task",
1823
+ "toolbar.commentDialogAria": "Add an optional comment",
1824
+ "toolbar.skipComment": "Skip comment",
1825
+ "toolbar.addComment": "Add comment",
1826
+ "card.commentSubtitle": "Comment · {comment}",
1827
+ "card.ariaIndexed": "Annotation {index}",
1828
+ "card.ariaPlain": "Selection annotation",
1829
+ "card.editComment": "Edit comment",
1830
+ "card.remove": "Remove annotation",
1831
+ "summary.hint": "Hover to view annotations",
1832
+ "summary.dialogAria": "Annotation details: {count}",
1833
+ "summary.edit": "Edit annotation {index}",
1834
+ "summary.remove": "Remove annotation {index}",
1835
+ "summary.userComment": "User comment",
1836
+ "summary.noComment": "No comment added",
1837
+ "summary.aria.one": "{count} annotation. Hover to view details.",
1838
+ "summary.aria.other": "{count} annotations. Hover to view details.",
1839
+ "summary.title.one": "{count} annotation",
1840
+ "summary.title.other": "{count} annotations",
1841
+ "summary.removeAll": "Remove all annotations",
1842
+ "chip.annotation": "Annotation {index}"
1843
+ };
1844
+ /**
1845
+ * Russian is not a shipped host locale: it is added through
1846
+ * `ctx.locale.addLanguage()` and its dictionary through the three-argument
1847
+ * `register()`. Its fallback chain ends at `en`, as the host requires.
1848
+ */
1849
+ const ru = {
1850
+ "common.selectedText": "Выделенный текст",
1851
+ "common.commentPlaceholder": "Добавить необязательный комментарий…",
1852
+ "common.cancel": "Отмена",
1853
+ "common.save": "Сохранить",
1854
+ "toolbar.aria": "Действия с выделенным текстом",
1855
+ "toolbar.commentHint": "Добавить аннотацию…",
1856
+ "toolbar.copy": "Копировать",
1857
+ "toolbar.copyFailed": "Не удалось скопировать",
1858
+ "toolbar.addToTask": "Добавить к задаче",
1859
+ "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1860
+ "toolbar.skipComment": "Без комментария",
1861
+ "toolbar.addComment": "Добавить комментарий",
1862
+ "card.commentSubtitle": "Комментарий · {comment}",
1863
+ "card.ariaIndexed": "Аннотация {index}",
1864
+ "card.ariaPlain": "Аннотация к выделенному тексту",
1865
+ "card.editComment": "Изменить комментарий",
1866
+ "card.remove": "Удалить аннотацию",
1867
+ "summary.hint": "Наведите, чтобы увидеть аннотации",
1868
+ "summary.dialogAria": "Детали аннотаций: {count}",
1869
+ "summary.edit": "Изменить аннотацию {index}",
1870
+ "summary.remove": "Удалить аннотацию {index}",
1871
+ "summary.userComment": "Комментарий пользователя",
1872
+ "summary.noComment": "Комментарий не добавлен",
1873
+ "summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
1874
+ "summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
1875
+ "summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1876
+ "summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1877
+ "summary.title.one": "{count} аннотация",
1878
+ "summary.title.few": "{count} аннотации",
1879
+ "summary.title.many": "{count} аннотаций",
1880
+ "summary.title.other": "{count} аннотаций",
1881
+ "summary.removeAll": "Удалить все аннотации",
1882
+ "chip.annotation": "Аннотация {index}"
1883
+ };
1884
+ /** Both shipped host locales, required together by the typed `register()`. */
1885
+ const SHIPPED_DICTS = Object.freeze({
1886
+ zh,
1887
+ en
1888
+ });
1889
+ /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
1890
+ const DICTIONARIES = Object.freeze({
1891
+ zh,
1892
+ en,
1893
+ ru
1894
+ });
1895
+ const LANGUAGE_RU = Object.freeze({
1896
+ id: "ru",
1897
+ label: "Русский",
1898
+ fallback: "en"
1899
+ });
1900
+ let api;
1901
+ /**
1902
+ * Install the host-bound translation entry point. Called once from `apply()`
1903
+ * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
1904
+ */
1905
+ function bindLocale(next) {
1906
+ api = next;
1907
+ }
1908
+ /** Active language id, or `en` before the host service is bound. */
1909
+ function activeLocale() {
1910
+ return api?.getActive() ?? "en";
1911
+ }
1912
+ function pluralForm(count) {
1913
+ const locale = activeLocale();
1914
+ if (locale === "ru") {
1915
+ const mod10 = count % 10;
1916
+ const mod100 = count % 100;
1917
+ if (mod10 === 1 && mod100 !== 11) return "one";
1918
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
1919
+ return "many";
1920
+ }
1921
+ if (locale === "en") return count === 1 ? "one" : "other";
1922
+ return "other";
1923
+ }
1924
+ /**
1925
+ * Translate a `select-quote` key. A numeric `count` selects a plural form and
1926
+ * the matching `key.form` entry is used, falling back to `key.other`.
1927
+ */
1928
+ function t(key, params = {}) {
1929
+ if (!api) return key;
1930
+ if (typeof params.count === "number") {
1931
+ const form = pluralForm(params.count);
1932
+ const exact = api.translate(`${key}.${form}`, params);
1933
+ if (exact !== `${key}.${form}`) return exact;
1934
+ const other = api.translate(`${key}.other`, params);
1935
+ if (other !== `${key}.other`) return other;
1936
+ }
1937
+ return api.translate(key, params);
1938
+ }
1939
+ /**
1940
+ * Subscribe to language or dictionary changes. Components go through
1941
+ * `useLocale()` instead; this is the non-React entry point (DOM decorations).
1942
+ * Subscribing before the host service is bound is a no-op, so callers subscribe
1943
+ * from `apply()`, after `bindLocale()`.
1944
+ */
1945
+ function subscribeLocale(listener) {
1946
+ return api ? api.subscribe(listener) : () => {};
1947
+ }
1948
+ /** Monotonic host revision counter, bumped on locale and dictionary changes. */
1949
+ function localeRevision() {
1950
+ return api?.getRevision() ?? 0;
1951
+ }
1952
+ //#endregion
1953
+ //#region src/client/useLocale.ts
1954
+ /**
1955
+ * Re-render the calling component when the host language changes, so slot copy
1956
+ * switches without a page reload. Subscriptions happen after mount, by which
1957
+ * time `apply()` has bound the locale service.
1958
+ */
1959
+ function useLocale() {
1960
+ (0, react.useSyncExternalStore)(subscribeLocale, localeRevision, localeRevision);
1961
+ }
1962
+ //#endregion
1707
1963
  //#region src/client/ui/AnnotationCardView.tsx
1964
+ function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
1965
+ useLocale();
1966
+ const composer = variant === "composer";
1967
+ const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
1968
+ return (0, react_jsx_runtime.jsxs)("div", {
1969
+ className: composer ? styles.card : styles.tCard,
1970
+ role: "group",
1971
+ "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
1972
+ children: [
1973
+ (0, react_jsx_runtime.jsx)("div", {
1974
+ className: composer ? styles.cardIcon : styles.tCardIcon,
1975
+ "aria-hidden": true,
1976
+ children: index ? String(index) : "AI"
1977
+ }),
1978
+ (0, react_jsx_runtime.jsxs)("div", {
1979
+ className: composer ? styles.cardBody : styles.tCardBody,
1980
+ children: [(0, react_jsx_runtime.jsx)("div", {
1981
+ className: composer ? styles.cardTitle : styles.tCardTitle,
1982
+ title: text,
1983
+ children: title
1984
+ }), (0, react_jsx_runtime.jsx)("div", {
1985
+ className: composer ? styles.cardSubtitle : styles.tCardSubtitle,
1986
+ ...comment ? {
1987
+ title: comment,
1988
+ "data-dsq-has-comment": "true"
1989
+ } : {},
1990
+ children: subtitle
1991
+ })]
1992
+ }),
1993
+ composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
1994
+ type: "button",
1995
+ className: styles.cardAction,
1996
+ "aria-label": t("card.editComment"),
1997
+ onClick: onEditComment,
1998
+ children: "✎"
1999
+ }) : null,
2000
+ composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
2001
+ type: "button",
2002
+ className: styles.cardClose,
2003
+ "aria-label": t("card.remove"),
2004
+ onClick: onRemove,
2005
+ children: "×"
2006
+ }) : null
2007
+ ]
2008
+ });
2009
+ }
1708
2010
  /** Grace period so pointer transit between the summary and the panel never closes it. */
1709
2011
  const CLOSE_DELAY = 320;
1710
2012
  /** After a viewport change, wait this long before position-checking the pointer. */
@@ -1720,7 +2022,8 @@ const SETTLE_DELAY = 600;
1720
2022
  * other cancels. Comment editing happens inline in the list — the panel stays
1721
2023
  * put and auto-close is suppressed while an edit is open.
1722
2024
  */
1723
- function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit, hint = "悬停查看批注" }) {
2025
+ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit }) {
2026
+ useLocale();
1724
2027
  const rootRef = (0, react.useRef)(null);
1725
2028
  const popRef = (0, react.useRef)(null);
1726
2029
  const closeTimerRef = (0, react.useRef)(null);
@@ -1876,7 +2179,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1876
2179
  ref: popRef,
1877
2180
  className: `${styles.summaryHover} ${styles.summaryHoverOpen}`,
1878
2181
  role: "dialog",
1879
- "aria-label": `${count} 条划词批注详情`,
2182
+ "aria-label": t("summary.dialogAria", { count }),
1880
2183
  onPointerEnter: openPop,
1881
2184
  onPointerLeave,
1882
2185
  children: (0, react_jsx_runtime.jsx)("div", {
@@ -1895,20 +2198,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1895
2198
  }),
1896
2199
  (0, react_jsx_runtime.jsx)("div", {
1897
2200
  className: styles.summaryItemLabel,
1898
- children: "选中的文本"
2201
+ children: t("common.selectedText")
1899
2202
  }),
1900
2203
  !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
1901
2204
  className: styles.summaryItemActions,
1902
2205
  children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
1903
2206
  type: "button",
1904
2207
  className: styles.summaryItemAction,
1905
- "aria-label": `编辑批注 ${i + 1}`,
2208
+ "aria-label": t("summary.edit", { index: i + 1 }),
1906
2209
  onClick: () => startEdit(i),
1907
2210
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
1908
2211
  }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
1909
2212
  type: "button",
1910
2213
  className: styles.summaryItemAction,
1911
- "aria-label": `移除批注 ${i + 1}`,
2214
+ "aria-label": t("summary.remove", { index: i + 1 }),
1912
2215
  onClick: () => onItemRemove(i, item),
1913
2216
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
1914
2217
  }) : null]
@@ -1923,12 +2226,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1923
2226
  }),
1924
2227
  (0, react_jsx_runtime.jsx)("div", {
1925
2228
  className: styles.summaryItemLabel,
1926
- children: "用户评论"
2229
+ children: t("summary.userComment")
1927
2230
  }),
1928
2231
  (0, react_jsx_runtime.jsx)("textarea", {
1929
2232
  ref: editInputRef,
1930
2233
  className: styles.annEditInput,
1931
- placeholder: "添加可选评论…",
2234
+ placeholder: t("common.commentPlaceholder"),
1932
2235
  value: editDraft,
1933
2236
  onChange: (e) => setEditDraft(e.target.value),
1934
2237
  onKeyDown: (e) => {
@@ -1949,12 +2252,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1949
2252
  type: "button",
1950
2253
  className: styles.annEditCancel,
1951
2254
  onClick: () => setEditing(null),
1952
- children: "取消"
2255
+ children: t("common.cancel")
1953
2256
  }), (0, react_jsx_runtime.jsx)("button", {
1954
2257
  type: "button",
1955
2258
  className: styles.annEditSave,
1956
2259
  onClick: commitEdit,
1957
- children: "保存"
2260
+ children: t("common.save")
1958
2261
  })]
1959
2262
  })
1960
2263
  ]
@@ -1974,11 +2277,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1974
2277
  }),
1975
2278
  (0, react_jsx_runtime.jsx)("div", {
1976
2279
  className: styles.summaryItemLabel,
1977
- children: "用户评论"
2280
+ children: t("summary.userComment")
1978
2281
  }),
1979
2282
  (0, react_jsx_runtime.jsx)("div", {
1980
2283
  className: item.comment ? styles.summaryItemText : `${styles.summaryItemText} ${styles.summaryItemTextEmpty}`,
1981
- children: item.comment || "未添加评论"
2284
+ children: item.comment || t("summary.noComment")
1982
2285
  })
1983
2286
  ]
1984
2287
  })
@@ -1992,7 +2295,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
1992
2295
  className: styles.summary,
1993
2296
  tabIndex: 0,
1994
2297
  "data-selection-annotation-summary": "true",
1995
- "aria-label": `${count} 条划词批注。悬停查看详情。`,
2298
+ "aria-label": t("summary.aria", { count }),
1996
2299
  "aria-expanded": open,
1997
2300
  onPointerEnter,
1998
2301
  onPointerLeave,
@@ -2009,16 +2312,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2009
2312
  className: styles.summaryBody,
2010
2313
  children: [(0, react_jsx_runtime.jsx)("div", {
2011
2314
  className: styles.summaryTitle,
2012
- children: `${count} 条批注`
2315
+ children: t("summary.title", { count })
2013
2316
  }), (0, react_jsx_runtime.jsx)("div", {
2014
2317
  className: styles.summaryHint,
2015
- children: hint
2318
+ children: t("summary.hint")
2016
2319
  })]
2017
2320
  }),
2018
2321
  onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2019
2322
  type: "button",
2020
2323
  className: styles.cardClose,
2021
- "aria-label": "移除全部划词批注",
2324
+ "aria-label": t("summary.removeAll"),
2022
2325
  onClick: onRemoveAll,
2023
2326
  children: (0, react_jsx_runtime.jsx)(LucideIcon, {
2024
2327
  name: "x",
@@ -2310,7 +2613,10 @@ async function copyText(text) {
2310
2613
  }
2311
2614
  //#endregion
2312
2615
  //#region src/client/SelectionToolbar.tsx
2616
+ /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2617
+ const COMMENT_MAX_HEIGHT = 220;
2313
2618
  function SelectionToolbar({ sessionId }) {
2619
+ useLocale();
2314
2620
  const toolbarRef = (0, react.useRef)(null);
2315
2621
  const commentWrapRef = (0, react.useRef)(null);
2316
2622
  const commentRef = (0, react.useRef)(null);
@@ -2376,19 +2682,24 @@ function SelectionToolbar({ sessionId }) {
2376
2682
  if (dragging) return;
2377
2683
  schedule();
2378
2684
  };
2685
+ const onScroll = (event) => {
2686
+ const target = event.target;
2687
+ if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
2688
+ hide();
2689
+ };
2379
2690
  document.addEventListener("pointerdown", onPointerDown, true);
2380
2691
  document.addEventListener("pointerup", onPointerUp, true);
2381
2692
  document.addEventListener("keyup", schedule, true);
2382
2693
  document.addEventListener("selectionchange", onSelectionChange);
2383
2694
  window.addEventListener("resize", hide);
2384
- document.addEventListener("scroll", hide, true);
2695
+ document.addEventListener("scroll", onScroll, true);
2385
2696
  return () => {
2386
2697
  document.removeEventListener("pointerdown", onPointerDown, true);
2387
2698
  document.removeEventListener("pointerup", onPointerUp, true);
2388
2699
  document.removeEventListener("keyup", schedule, true);
2389
2700
  document.removeEventListener("selectionchange", onSelectionChange);
2390
2701
  window.removeEventListener("resize", hide);
2391
- document.removeEventListener("scroll", hide, true);
2702
+ document.removeEventListener("scroll", onScroll, true);
2392
2703
  };
2393
2704
  }, [hide, refresh]);
2394
2705
  const placeCommentPop = (0, react.useCallback)(() => {
@@ -2415,6 +2726,18 @@ function SelectionToolbar({ sessionId }) {
2415
2726
  commentRef.current?.focus();
2416
2727
  }
2417
2728
  }, [phase, placeCommentPop]);
2729
+ (0, react.useEffect)(() => {
2730
+ if (phase !== "comment") return;
2731
+ const area = commentRef.current;
2732
+ if (!area) return;
2733
+ area.style.height = "auto";
2734
+ area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
2735
+ placeCommentPop();
2736
+ }, [
2737
+ phase,
2738
+ comment,
2739
+ placeCommentPop
2740
+ ]);
2418
2741
  const handleToolbarPointerDown = (0, react.useCallback)((event) => {
2419
2742
  event.preventDefault();
2420
2743
  event.stopPropagation();
@@ -2427,7 +2750,7 @@ function SelectionToolbar({ sessionId }) {
2427
2750
  clearNativeSelection();
2428
2751
  hide();
2429
2752
  } catch {
2430
- setStatus("复制失败");
2753
+ setStatus(t("toolbar.copyFailed"));
2431
2754
  }
2432
2755
  }, [hide]);
2433
2756
  const openComment = (0, react.useCallback)(() => {
@@ -2436,7 +2759,7 @@ function SelectionToolbar({ sessionId }) {
2436
2759
  setComment("");
2437
2760
  setStatus(null);
2438
2761
  }, []);
2439
- /** Quick-add without opening the comment step (double-tap / “暂不评论”). */
2762
+ /** Quick-add without opening the comment step (double-tap / “Skip comment”). */
2440
2763
  const commitAnnotation = (0, react.useCallback)((body) => {
2441
2764
  const snapshot = activeRef.current;
2442
2765
  if (!snapshot) {
@@ -2471,7 +2794,7 @@ function SelectionToolbar({ sessionId }) {
2471
2794
  className: styles.toolbar,
2472
2795
  style: toolbarStyle,
2473
2796
  role: "toolbar",
2474
- "aria-label": "划词操作",
2797
+ "aria-label": t("toolbar.aria"),
2475
2798
  "data-dsq-toolbar": "true",
2476
2799
  onPointerDown: handleToolbarPointerDown,
2477
2800
  children: phase === "comment" ? (0, react_jsx_runtime.jsx)("div", {
@@ -2486,7 +2809,7 @@ function SelectionToolbar({ sessionId }) {
2486
2809
  fontSize: 13,
2487
2810
  opacity: .8
2488
2811
  },
2489
- children: "添加批注…"
2812
+ children: t("toolbar.commentHint")
2490
2813
  })
2491
2814
  }) : (0, react_jsx_runtime.jsxs)("div", {
2492
2815
  style: {
@@ -2502,7 +2825,7 @@ function SelectionToolbar({ sessionId }) {
2502
2825
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2503
2826
  name: "copy",
2504
2827
  size: 14
2505
- }), "复制"]
2828
+ }), t("toolbar.copy")]
2506
2829
  }),
2507
2830
  (0, react_jsx_runtime.jsx)("span", {
2508
2831
  className: styles.divider,
@@ -2515,7 +2838,7 @@ function SelectionToolbar({ sessionId }) {
2515
2838
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2516
2839
  name: "plus",
2517
2840
  size: 14
2518
- }), "添加到任务"]
2841
+ }), t("toolbar.addToTask")]
2519
2842
  })
2520
2843
  ]
2521
2844
  })
@@ -2525,12 +2848,12 @@ function SelectionToolbar({ sessionId }) {
2525
2848
  className: styles.commentPop,
2526
2849
  style: void 0,
2527
2850
  role: "dialog",
2528
- "aria-label": "添加可选评论",
2851
+ "aria-label": t("toolbar.commentDialogAria"),
2529
2852
  onPointerDown: handleToolbarPointerDown,
2530
2853
  children: [(0, react_jsx_runtime.jsx)("textarea", {
2531
2854
  ref: commentRef,
2532
2855
  className: styles.commentInput,
2533
- placeholder: "添加可选评论…",
2856
+ placeholder: t("common.commentPlaceholder"),
2534
2857
  value: comment,
2535
2858
  onChange: (e) => setComment(e.target.value),
2536
2859
  onKeyDown: (e) => {
@@ -2550,13 +2873,13 @@ function SelectionToolbar({ sessionId }) {
2550
2873
  type: "button",
2551
2874
  className: styles.button,
2552
2875
  onClick: () => commitAnnotation(void 0),
2553
- children: "暂不评论"
2876
+ children: t("toolbar.skipComment")
2554
2877
  }), (0, react_jsx_runtime.jsx)("button", {
2555
2878
  type: "button",
2556
2879
  className: styles.button,
2557
2880
  disabled: !comment.trim(),
2558
2881
  onClick: () => commitAnnotation(comment),
2559
- children: "添加评论"
2882
+ children: t("toolbar.addComment")
2560
2883
  })]
2561
2884
  })]
2562
2885
  }) : null,
@@ -2581,11 +2904,14 @@ function decorateTextNode(node) {
2581
2904
  const parent = node.parentElement;
2582
2905
  if (!parent || parent.closest(`[data-${DECO}]`)) return;
2583
2906
  if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
2907
+ if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
2584
2908
  const frag = document.createDocumentFragment();
2585
2909
  let last = 0;
2910
+ let matched = false;
2586
2911
  const re = new RegExp(ANNOTATION_DIRECTIVE_RE.source, "g");
2587
2912
  let m;
2588
2913
  while ((m = re.exec(value)) !== null) {
2914
+ matched = true;
2589
2915
  if (m.index > last) frag.append(document.createTextNode(value.slice(last, m.index)));
2590
2916
  const index = Number(m[1]);
2591
2917
  const chip = document.createElement("button");
@@ -2593,7 +2919,7 @@ function decorateTextNode(node) {
2593
2919
  chip.className = "dsq_annDirective";
2594
2920
  chip.dataset[DECO] = String(index);
2595
2921
  chip.setAttribute("data-dsq-deco", "true");
2596
- chip.textContent = `批注 ${index}`;
2922
+ chip.textContent = t("chip.annotation", { index });
2597
2923
  chip.addEventListener("click", () => {
2598
2924
  const mark = findAnnotationMarkBefore(index, chip);
2599
2925
  if (mark) flashAnnotationMark(mark);
@@ -2601,6 +2927,7 @@ function decorateTextNode(node) {
2601
2927
  frag.append(chip);
2602
2928
  last = m.index + m[0].length;
2603
2929
  }
2930
+ if (!matched) return;
2604
2931
  if (last < value.length) frag.append(document.createTextNode(value.slice(last)));
2605
2932
  parent.replaceChild(frag, node);
2606
2933
  }
@@ -2617,6 +2944,22 @@ function decorateAnnotationDirectives(root) {
2617
2944
  }
2618
2945
  for (const node of hits) decorateTextNode(node);
2619
2946
  }
2947
+ /**
2948
+ * Re-label chips that are already on screen after the language changed.
2949
+ *
2950
+ * Chip text is plain DOM, not React state, so a switch would otherwise leave
2951
+ * old-language chips next to new-language popovers. Writing `textContent` here
2952
+ * is safe for the observer: the new text node never contains the directive
2953
+ * literal, so `decorateTextNode()` returns before it can touch the DOM.
2954
+ */
2955
+ function refreshDirectiveChips(root) {
2956
+ const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
2957
+ if (!el) return;
2958
+ for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
2959
+ const index = Number(chip.dataset[DECO]);
2960
+ if (Number.isFinite(index)) chip.textContent = t("chip.annotation", { index });
2961
+ }
2962
+ }
2620
2963
  /** Observe chat DOM and decorate new directive text. Returns disposer. */
2621
2964
  function watchAnnotationDirectives(root) {
2622
2965
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
@@ -2635,7 +2978,16 @@ function watchAnnotationDirectives(root) {
2635
2978
  }
2636
2979
  //#endregion
2637
2980
  //#region src/client/transcript/TranscriptQuoteCard.tsx
2638
- /** History-side annotation summary — same chrome as the composer. */
2981
+ /**
2982
+ * History-side cards for one user message's annotations.
2983
+ *
2984
+ * One card per annotation, each showing the selected text **and** the user's
2985
+ * comment. The comment is how the user recognizes their own annotation, so it
2986
+ * belongs on the visible card — not behind a hover panel that has to be
2987
+ * discovered. Clicking a card scrolls back to the marked text in the source
2988
+ * message; the ordinal badge matches the `:dsh-annotation{index="N"}` the model
2989
+ * refers to.
2990
+ */
2639
2991
  function TranscriptQuoteCard({ node }) {
2640
2992
  ensureToolbarStyles();
2641
2993
  const stackRef = (0, react.useRef)(null);
@@ -2652,28 +3004,37 @@ function TranscriptQuoteCard({ node }) {
2652
3004
  endOffset: quote.endOffset
2653
3005
  })));
2654
3006
  }, [quotes]);
2655
- const onItemSelect = (0, react.useCallback)((index, item) => {
2656
- scrollToAnnotation(index + 1, item.text, item.id);
2657
- }, []);
2658
3007
  const markIdOf = (0, react.useCallback)((index) => {
2659
3008
  const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
2660
3009
  return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
2661
3010
  }, []);
3011
+ const onSelect = (0, react.useCallback)((index, text) => {
3012
+ scrollToAnnotation(index + 1, text, markIdOf(index));
3013
+ }, [markIdOf]);
2662
3014
  if (quotes.length === 0) return null;
2663
3015
  return (0, react_jsx_runtime.jsx)("div", {
2664
3016
  ref: stackRef,
2665
3017
  className: styles.tCardStack,
2666
3018
  "data-dsq-deco": "true",
2667
- children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2668
- count: quotes.length,
2669
- items: quotes.map((quote, index) => ({
2670
- id: markIdOf(index),
3019
+ children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
3020
+ className: styles.tCardButton,
3021
+ role: "button",
3022
+ tabIndex: 0,
3023
+ "aria-label": quote.title,
3024
+ onClick: () => onSelect(index, quote.text),
3025
+ onKeyDown: (event) => {
3026
+ if (event.key !== "Enter" && event.key !== " ") return;
3027
+ event.preventDefault();
3028
+ onSelect(index, quote.text);
3029
+ },
3030
+ children: (0, react_jsx_runtime.jsx)(AnnotationCardView, {
3031
+ variant: "transcript",
2671
3032
  title: quote.title,
2672
3033
  text: quote.text,
2673
- comment: quote.comment || void 0
2674
- })),
2675
- onItemSelect
2676
- })
3034
+ comment: quote.comment || void 0,
3035
+ index: index + 1
3036
+ })
3037
+ }, `ann-${index}`))
2677
3038
  });
2678
3039
  }
2679
3040
  //#endregion
@@ -2691,101 +3052,89 @@ function contentImages(content) {
2691
3052
  }
2692
3053
  /**
2693
3054
  * Replacement for the built-in `user` Chat node view.
2694
- * Strips the `<response-annotations>` protocol block from the bubble.
3055
+ *
3056
+ * Strips the `<response-annotations>` protocol block from the bubble **and**
3057
+ * renders one card per annotation right under it.
3058
+ *
3059
+ * Why the cards live here instead of on a `select-quote` Chat node:
3060
+ * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
3061
+ * allowlist (system-prompt, user, steering, turn-trigger, turn-process,
3062
+ * turn-error, turn-max-tokens, turn-tail). A node whose kind is not on that list
3063
+ * is classified as a turn-process member and `hidden` inside the collapsed
3064
+ * process disclosure of a completed turn — mounted, then invisible. `user` is on
3065
+ * the list, so rendering the cards inside this component is the only placement
3066
+ * that survives turn folding.
2695
3067
  */
2696
3068
  function UserMessageDisplay({ node, renderMessageImages }) {
2697
3069
  ensureToolbarStyles();
2698
3070
  const raw = contentBlocksToText(node.data?.content);
2699
3071
  const scanned = scanAnnotatedMessage(raw);
2700
3072
  const text = scanned.annotations.length > 0 ? scanned.rest : displayTextWithoutQuote(raw).trim();
3073
+ const quotes = scanned.annotations;
2701
3074
  const images = contentImages(node.data?.content);
2702
- if (!text && images.length === 0) return null;
3075
+ if (!text && images.length === 0 && quotes.length === 0) return null;
2703
3076
  return (0, react_jsx_runtime.jsx)("div", {
2704
3077
  className: styles.userRow,
2705
3078
  children: (0, react_jsx_runtime.jsxs)("div", {
2706
3079
  className: styles.userStack,
2707
- children: [images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
2708
- className: styles.userImages,
2709
- "data-message-attachments": true,
2710
- children: renderMessageImages({
2711
- images,
2712
- align: "end",
2713
- compact: images.length > 1
2714
- })
2715
- }, "images") : null, text ? (0, react_jsx_runtime.jsx)("div", {
2716
- className: styles.userBubble,
2717
- children: text
2718
- }, "bubble") : null]
3080
+ children: [
3081
+ images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3082
+ className: styles.userImages,
3083
+ "data-message-attachments": true,
3084
+ children: renderMessageImages({
3085
+ images,
3086
+ align: "end",
3087
+ compact: images.length > 1
3088
+ })
3089
+ }, "images") : null,
3090
+ text ? (0, react_jsx_runtime.jsx)("div", {
3091
+ className: styles.userBubble,
3092
+ children: text
3093
+ }, "bubble") : null,
3094
+ quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null
3095
+ ]
2719
3096
  })
2720
3097
  });
2721
3098
  }
2722
3099
  //#endregion
2723
- //#region src/client/transcript/transcript-node.ts
2724
- const KIND = "select-quote";
2725
- function createSelectQuoteDefinition() {
2726
- return {
2727
- kind: KIND,
2728
- target: "chat",
2729
- match(event) {
2730
- if (event.type !== "user/message") return null;
2731
- if (parseAnnotatedMessage(extractUserText(event)).length === 0) return null;
2732
- return {
2733
- id: `${KIND}-${event.seq ?? 0}`,
2734
- role: "start"
2735
- };
2736
- },
2737
- start(_context, match) {
2738
- const quotes = parseAnnotatedMessage(extractUserText(match.event));
2739
- if (quotes.length === 0) throw new Error("select-quote: missing annotation payload on start");
2740
- return {
2741
- quotes,
2742
- seq: match.event.seq ?? 0
2743
- };
2744
- },
2745
- update(context) {
2746
- return context.state;
2747
- },
2748
- buildViewNode(context) {
2749
- if (context.state === void 0) return null;
2750
- const anchor = context.start?.event.seq ?? context.matches?.[0]?.event.seq ?? context.state.seq ?? 0;
2751
- const location = context.start?.location ?? context.matches?.[0]?.location ?? { kind: "unresolved" };
2752
- return {
2753
- key: context.key,
2754
- kind: KIND,
2755
- id: context.id,
2756
- target: "chat",
2757
- anchorSeq: anchor,
2758
- location,
2759
- visibility: "visible",
2760
- data: context.state
2761
- };
2762
- }
2763
- };
2764
- }
2765
- function registerSelectQuoteNode(ctx) {
2766
- const uiConversation = ctx.uiConversation;
2767
- if (!uiConversation) {
2768
- console.warn("[dsh-select-quote] uiConversation unavailable; transcript card disabled");
2769
- return;
2770
- }
2771
- ctx.effect(() => uiConversation.events.register(createSelectQuoteDefinition()), "dsh-select-quote: conversation node definition");
2772
- }
2773
- //#endregion
2774
3100
  //#region src/client/index.tsx
2775
3101
  /**
2776
3102
  * Browser half of dsh-select-quote.
2777
3103
  *
2778
3104
  * Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
2779
3105
  * are always observed. Annotation summary stays on the session composer.
3106
+ *
3107
+ * History cards render **inside** the `user` Chat node view, not on a
3108
+ * `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
3109
+ * `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
3110
+ * turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
3111
+ * kind is classified as a turn-process member and `hidden` when the completed
3112
+ * turn's process disclosure is folded — mounted, but invisible. `user` is on
3113
+ * that allowlist, so the bubble is the only placement that is reliably visible.
3114
+ *
3115
+ * Language is the host's decision, never this plugin's: dictionaries go to
3116
+ * `ctx.locale`, and `t()` resolves against whatever language the user picked in
3117
+ * Settings. Self-detecting from `navigator` would disagree with the host on the
3118
+ * desktop shell, where Electron pins the document language (`--lang`) to its own
3119
+ * value while the host preference comes through `__DSH_LOCALE__`.
2780
3120
  */
2781
3121
  const inject = [
2782
3122
  "slots",
2783
3123
  "sessions",
2784
- "uiConversation"
3124
+ "locale"
2785
3125
  ];
2786
3126
  function apply(ctx) {
2787
3127
  ensureToolbarStyles();
2788
- registerSelectQuoteNode(ctx);
3128
+ ctx.effect(() => ctx.locale.addLanguage(LANGUAGE_RU), "dsh-select-quote: ru language");
3129
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, SHIPPED_DICTS), "dsh-select-quote: dictionaries (zh, en)");
3130
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, LANGUAGE_RU.id, DICTIONARIES.ru), "dsh-select-quote: dictionary (ru)");
3131
+ bindLocale({
3132
+ translate: ctx.locale.bind(LOCALE_NS),
3133
+ getActive: () => ctx.locale.getLocale().active,
3134
+ getRevision: () => ctx.locale.getSnapshot().revision,
3135
+ subscribe: (listener) => ctx.locale.subscribe(listener)
3136
+ });
3137
+ ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
2789
3138
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
2790
3139
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
2791
3140
  ctx.effect(() => {
@@ -2814,10 +3163,6 @@ function apply(ctx) {
2814
3163
  }, SessionBridge);
2815
3164
  });
2816
3165
  ctx.slots.inject("conversation.chat.node", () => {
2817
- ctx.slots.register({
2818
- name: "conversation.chat.node",
2819
- key: "select-quote"
2820
- }, TranscriptQuoteCard);
2821
3166
  ctx.slots.register({
2822
3167
  name: "conversation.chat.node",
2823
3168
  key: "user",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -39,7 +39,8 @@
39
39
  "watch": "node scripts/build.mjs --watch",
40
40
  "typecheck": "tsc -p tsconfig.json --noEmit",
41
41
  "verify": "node scripts/verify-bundle.mjs",
42
- "check": "npm run typecheck && npm run build && npm run verify"
42
+ "check": "npm run typecheck && npm run build && npm run verify",
43
+ "release": "npm run check && commit-and-tag-version"
43
44
  },
44
45
  "dsh": {
45
46
  "client": {
@@ -66,8 +67,10 @@
66
67
  "devDependencies": {
67
68
  "@types/react": "~18.3.1",
68
69
  "@types/react-dom": "~18.3",
70
+ "commit-and-tag-version": "^13.2.2",
69
71
  "react": "^18.2.0",
70
72
  "react-dom": "^18.2.0",
73
+ "tsdown": "0.23.0",
71
74
  "typescript": "^5.8.0"
72
75
  }
73
76
  }