dsh-select-quote 0.3.10 → 0.3.11

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 +9 -6
  2. package/lib/client.js +550 -414
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  - **右键菜单**:在选区矩形内点右键,同一套「复制 / 打开链接 / 添加到任务」出现在指针处;选区之外、其它插件已接管的右键一律不碰,桌面端未接管时保留宿主的原生 Copy 菜单
7
7
  - **快捷键**:选中文字后一键打开菜单或直接添加;桌面 macOS 是 `⌘L` / `⇧⌘L`,其它平台的默认键位见下表;可在设置里单独开关
8
8
  - **批注摘要**:添加后输入框上方出现一块「N 条批注」摘要,草稿里不会出现任何协议文本
9
- - **正文标记**:被划中的文字在原消息里留下淡色底、虚线下划线和序号角标,点摘要条目会滚回那段文字;角标始终压在选区高亮之上,多条批注结尾重合时会自动向右/向上错开
9
+ - **正文标记**:被划中的文字在原消息里留下淡色底、虚线下划线和序号角标,点摘要条目会滚回那段文字;点标记或角标会打开该条批注的预览(原文 + 评论),角标也能用 Tab 聚焦、Enter 打开。角标始终压在选区高亮之上,多条批注结尾重合时会自动向右/向上错开
10
10
  - **模型回指**:模型回答里指向某条批注的位置,渲染成带预览的 chip
11
11
  - **对话记录**:已发送消息仍由宿主气泡渲染(时间、复制、撤回都在),批注收成一块摘要,悬停(触屏点按)展开列表
12
12
  - **界面语言**:跟随 dsh 自己的语言设置,简体中文 / English / Русский
@@ -44,8 +44,9 @@ dsh web --patch "$PWD/dev.patch.yml"
44
44
  1. 在对话区选中一段文本,点「添加到任务」——到这一步批注就已经保存,输入框上方同步出现批注摘要。触屏下操作条固定在屏幕底部,不跟随选区挤在系统选择菜单旁边
45
45
  2. 想补充的话在弹出的评论框里写一句,点「添加评论」或 `⌘/Ctrl + Enter`;不写就直接点空白处或「暂不评论」,批注同样保留。触屏下评论在底部抽屉里完成,抽屉顶部会带上选中的原文
46
46
  3. 悬停(触屏点按)输入框上方的摘要展开列表,可逐条编辑、删除或一键清空,点条目跳回正文对应文字;对话记录里的摘要只提供列表和跳转。键盘聚焦摘要后按 Enter / ↓ 展开,Tab 在面板内移动,Esc 关闭并回到摘要
47
- 4. 正常打字发送,批注随消息一起交给模型,摘要随之消失
48
- 5. 模型回答里引用某条批注的位置显示为 chip:悬停、点击(触屏点按)或键盘聚焦都会打开预览,显示原文和评论;预览默认在 chip 上方,右上角「原文」按钮回到正文;键盘聚焦 chip 后按 ↓ 进入按钮,Esc 关闭
47
+ 4. 点正文里的标记或角标(角标可用 Tab 聚焦后按 Enter)打开批注预览,查看原文和评论;点浮层外或按 Esc 关闭
48
+ 5. 正常打字发送,批注随消息一起交给模型,摘要随之消失
49
+ 6. 模型回答里引用某条批注的位置显示为 chip:悬停、点击(触屏点按)或键盘聚焦都会打开预览,显示原文和评论;预览默认在 chip 上方,右上角「原文」按钮回到正文;键盘聚焦 chip 后按 ↓ 进入按钮,Esc 关闭
49
50
 
50
51
  ### 入口与菜单
51
52
 
@@ -93,7 +94,7 @@ dsh web --patch "$PWD/dev.patch.yml"
93
94
  - **划词只在对话区生效**:不介入编辑器、终端等面板。
94
95
  - **右键只在选区矩形内接管**:指针在选区外、右键已被其它插件处理、或触发方式没开「右键菜单」时,插件完全不介入,宿主自己的菜单照常出现。桌面端接管时原生 Copy 菜单会被抑制;没接管时仍由客户端弹原生菜单,这是两条互斥的路径。
95
96
  - **Web Linux 快捷键只有两个可用组合**:宿主的 Web 快捷键只接受 `Ctrl+/`、`Ctrl+Shift+,`、`Ctrl+Shift+.` 三种组合,其中 `Ctrl+/` 已被宿主的快捷键面板占用,所以本插件用后两个,且不能像其它平台一样用字母键。桌面 Windows/Linux 用 `Ctrl+Shift+L` / `Ctrl+Shift+.`,不影响 `Ctrl+L` 的平台含义,也避开 AltGr 会打出字符的组合。
96
- - **创建选区仍依赖指针**:对话正文是静态渲染,没有可放置的键盘光标;键盘用户需借助光标浏览或辅助技术建立选区。选区建立后的工具条按钮可聚焦,摘要面板已支持 Enter / ↓ / Tab / Esc 与触屏点按。
97
+ - **创建选区仍依赖指针**:对话正文是静态渲染,没有可放置的键盘光标;键盘用户需借助光标浏览或辅助技术建立选区。选区建立后的工具条按钮可聚焦,摘要面板已支持 Enter / ↓ / Tab / Esc 与触屏点按;已有批注可以从正文角标 Tab 聚焦后按 Enter 回看。
97
98
 
98
99
  ## 常见问题
99
100
 
@@ -115,13 +116,15 @@ dsh web --patch "$PWD/dev.patch.yml"
115
116
 
116
117
  ```bash
117
118
  pnpm install
118
- pnpm run check # typecheck + build + verify
119
+ pnpm run check # typecheck + test + build + verify
119
120
  pnpm run watch # 增量重建
120
121
  ```
121
122
 
122
123
  依赖用 pnpm 管理(锁文件 `pnpm-lock.yaml`);构建产物 `lib/` 随源码一起提交,运行时直接读它。
123
124
 
124
- `pnpm run verify` 无头执行产物:断言 10 项贡献都注册上,检查三份词典的键与 `{placeholder}` 在每种语言里都齐备(复数消息必须有 `.other` 兜底)、插件页展示元信息(locale + icon)按宿主规则解析、`npm pack` 实际发货清单。它还会回放批注持久化与跨标签同步、链接识别、宿主视图解析、发送手势、协议块读写与回声剥离、右键接管判定与两个快捷键命令等关键路径。
125
+ `pnpm run test` 用 Vitest 跑纯逻辑(角标避让、评论泡定位、浮层翻转、触发矩阵、配置归一、链接识别、快捷键默认矩阵);`pnpm run verify` 无头执行产物:断言 10 项贡献都注册上,检查三份词典的键与 `{placeholder}` 在每种语言里都齐备(复数消息必须有 `.other` 兜底)、插件页展示元信息(locale + icon)按宿主规则解析、`npm pack` 实际发货清单。它还会回放批注持久化与跨标签同步、链接识别、宿主视图解析、发送手势、协议块读写与回声剥离、右键接管判定与两个快捷键命令等关键路径。
126
+
127
+ CI 在 push 与 PR 上跑同一套 `pnpm run check`(`.github/workflows/check.yml`)。
125
128
 
126
129
  ### 热更新到桌面客户端
127
130
 
package/lib/client.js CHANGED
@@ -64,6 +64,246 @@ function lastEditable() {
64
64
  return null;
65
65
  }
66
66
  //#endregion
67
+ //#region src/client/i18n.ts
68
+ /**
69
+ * Copy for dsh-select-quote, served through the host locale service.
70
+ *
71
+ * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
72
+ * General → Language, persisted as `locale.preference`). This module owns only
73
+ * the dictionaries and the plural form selection that the host's plain-template
74
+ * lookup cannot express; it never resolves a language on its own.
75
+ *
76
+ * NOT localized on purpose:
77
+ * - wire markers (`> [选中文本]`, `<response-annotations>`,
78
+ * `:dsh-annotation{index="N"}`) — they are the message format the model
79
+ * and the transcript parser agree on;
80
+ * - the `# Response annotations:` header — model-facing instruction prose,
81
+ * not UI text.
82
+ */
83
+ const LOCALE_NS = "select-quote";
84
+ const zh = {
85
+ "common.selectedText": "选中的文本",
86
+ "common.commentPlaceholder": "添加可选评论…",
87
+ "common.cancel": "取消",
88
+ "common.close": "关闭",
89
+ "common.save": "保存",
90
+ "toolbar.aria": "划词操作",
91
+ "toolbar.commentHint": "添加批注…",
92
+ "toolbar.copy": "复制",
93
+ "toolbar.openLink": "打开链接",
94
+ "toolbar.copyFailed": "复制失败",
95
+ "toolbar.addToTask": "添加到任务",
96
+ "toolbar.commentDialogAria": "添加可选评论",
97
+ "toolbar.skipComment": "暂不评论",
98
+ "toolbar.addComment": "添加评论",
99
+ "menu.aria": "划词菜单",
100
+ "menu.shortcutLabel": "打开划词菜单",
101
+ "menu.addShortcutLabel": "将选中文字添加到任务",
102
+ "menu.noSelection": "先在对话里选中一段文字",
103
+ "menu.busy": "先完成当前批注",
104
+ "summary.hint": "悬停查看批注",
105
+ "summary.hintTouch": "点按查看批注",
106
+ "summary.dialogAria": "{count} 条划词批注详情",
107
+ "summary.edit": "编辑批注 {index}",
108
+ "summary.remove": "移除批注 {index}",
109
+ "summary.userComment": "用户评论",
110
+ "summary.noComment": "未添加评论",
111
+ "summary.aria.other": "{count} 条划词批注。展开查看详情。",
112
+ "summary.title.other": "{count} 条批注",
113
+ "summary.removeAll": "移除全部划词批注",
114
+ "chip.annotation": "批注 {index}",
115
+ "chip.previewAria": "批注预览",
116
+ "mark.previewAria": "查看第 {index} 条批注",
117
+ "chip.jump": "原文",
118
+ "chip.jumpAria": "跳转到原文",
119
+ "settings.trigger": "划词入口",
120
+ "settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
121
+ "settings.trigger.auto": "自动浮出",
122
+ "settings.trigger.contextmenu": "右键菜单",
123
+ "settings.trigger.both": "两者都开",
124
+ "settings.shortcut": "快捷键",
125
+ "settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
126
+ "settings.shortcutListAria": "当前快捷键",
127
+ "settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
128
+ "settings.shortcutUnbound": "未绑定",
129
+ "settings.shortcutConflict": "与其它快捷键冲突"
130
+ };
131
+ const en = {
132
+ "common.selectedText": "Selected text",
133
+ "common.commentPlaceholder": "Add an optional comment…",
134
+ "common.cancel": "Cancel",
135
+ "common.close": "Close",
136
+ "common.save": "Save",
137
+ "toolbar.aria": "Selection actions",
138
+ "toolbar.commentHint": "Add annotation…",
139
+ "toolbar.copy": "Copy",
140
+ "toolbar.openLink": "Open link",
141
+ "toolbar.copyFailed": "Copy failed",
142
+ "toolbar.addToTask": "Add to task",
143
+ "toolbar.commentDialogAria": "Add an optional comment",
144
+ "toolbar.skipComment": "Skip comment",
145
+ "toolbar.addComment": "Add comment",
146
+ "menu.aria": "Selection menu",
147
+ "menu.shortcutLabel": "Open selection menu",
148
+ "menu.addShortcutLabel": "Add selected text to task",
149
+ "menu.noSelection": "Select text in the conversation first",
150
+ "menu.busy": "Finish the current annotation first",
151
+ "summary.hint": "Hover to view annotations",
152
+ "summary.hintTouch": "Tap to view annotations",
153
+ "summary.dialogAria": "Annotation details: {count}",
154
+ "summary.edit": "Edit annotation {index}",
155
+ "summary.remove": "Remove annotation {index}",
156
+ "summary.userComment": "User comment",
157
+ "summary.noComment": "No comment added",
158
+ "summary.aria.one": "{count} annotation. Open to view details.",
159
+ "summary.aria.other": "{count} annotations. Open to view details.",
160
+ "summary.title.one": "{count} annotation",
161
+ "summary.title.other": "{count} annotations",
162
+ "summary.removeAll": "Remove all annotations",
163
+ "chip.annotation": "Annotation {index}",
164
+ "chip.previewAria": "Annotation preview",
165
+ "mark.previewAria": "View annotation {index}",
166
+ "chip.jump": "Original",
167
+ "chip.jumpAria": "Jump to original",
168
+ "settings.trigger": "Trigger",
169
+ "settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
170
+ "settings.trigger.auto": "Automatic",
171
+ "settings.trigger.contextmenu": "Right-click",
172
+ "settings.trigger.both": "Both",
173
+ "settings.shortcut": "Shortcut",
174
+ "settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
175
+ "settings.shortcutListAria": "Active shortcuts",
176
+ "settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
177
+ "settings.shortcutUnbound": "Unbound",
178
+ "settings.shortcutConflict": "Conflicts with another shortcut"
179
+ };
180
+ /**
181
+ * Russian is not a shipped host locale: it is added through
182
+ * `ctx.locale.addLanguage()` and its dictionary through the three-argument
183
+ * `register()`. Its fallback chain ends at `en`, as the host requires.
184
+ */
185
+ const ru = {
186
+ "common.selectedText": "Выделенный текст",
187
+ "common.commentPlaceholder": "Добавить необязательный комментарий…",
188
+ "common.cancel": "Отмена",
189
+ "common.close": "Закрыть",
190
+ "common.save": "Сохранить",
191
+ "toolbar.aria": "Действия с выделенным текстом",
192
+ "toolbar.commentHint": "Добавить аннотацию…",
193
+ "toolbar.copy": "Копировать",
194
+ "toolbar.openLink": "Открыть ссылку",
195
+ "toolbar.copyFailed": "Не удалось скопировать",
196
+ "toolbar.addToTask": "Добавить к задаче",
197
+ "toolbar.commentDialogAria": "Добавить необязательный комментарий",
198
+ "toolbar.skipComment": "Без комментария",
199
+ "toolbar.addComment": "Добавить комментарий",
200
+ "menu.aria": "Меню выделения",
201
+ "menu.shortcutLabel": "Открыть меню выделения",
202
+ "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
203
+ "menu.noSelection": "Сначала выделите текст в диалоге",
204
+ "menu.busy": "Сначала завершите текущую аннотацию",
205
+ "summary.hint": "Наведите, чтобы увидеть аннотации",
206
+ "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
207
+ "summary.dialogAria": "Детали аннотаций: {count}",
208
+ "summary.edit": "Изменить аннотацию {index}",
209
+ "summary.remove": "Удалить аннотацию {index}",
210
+ "summary.userComment": "Комментарий пользователя",
211
+ "summary.noComment": "Комментарий не добавлен",
212
+ "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
213
+ "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
214
+ "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
215
+ "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
216
+ "summary.title.one": "{count} аннотация",
217
+ "summary.title.few": "{count} аннотации",
218
+ "summary.title.many": "{count} аннотаций",
219
+ "summary.title.other": "{count} аннотаций",
220
+ "summary.removeAll": "Удалить все аннотации",
221
+ "chip.annotation": "Аннотация {index}",
222
+ "chip.previewAria": "Просмотр аннотации",
223
+ "mark.previewAria": "Открыть аннотацию {index}",
224
+ "chip.jump": "Оригинал",
225
+ "chip.jumpAria": "Перейти к оригиналу",
226
+ "settings.trigger": "Триггер",
227
+ "settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
228
+ "settings.trigger.auto": "Автоматически",
229
+ "settings.trigger.contextmenu": "По правому клику",
230
+ "settings.trigger.both": "Оба способа",
231
+ "settings.shortcut": "Горячая клавиша",
232
+ "settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
233
+ "settings.shortcutListAria": "Текущие сочетания",
234
+ "settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
235
+ "settings.shortcutUnbound": "Не назначено",
236
+ "settings.shortcutConflict": "Конфликтует с другим сочетанием"
237
+ };
238
+ /** Both shipped host locales, required together by the typed `register()`. */
239
+ const SHIPPED_DICTS = Object.freeze({
240
+ zh,
241
+ en
242
+ });
243
+ /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
244
+ const DICTIONARIES = Object.freeze({
245
+ zh,
246
+ en,
247
+ ru
248
+ });
249
+ const LANGUAGE_RU = Object.freeze({
250
+ id: "ru",
251
+ label: "Русский",
252
+ fallback: "en"
253
+ });
254
+ let api;
255
+ /**
256
+ * Install the host-bound translation entry point. Called once from `apply()`
257
+ * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
258
+ */
259
+ function bindLocale(next) {
260
+ api = next;
261
+ }
262
+ /** Active language id, or `en` before the host service is bound. */
263
+ function activeLocale() {
264
+ return api?.getActive() ?? "en";
265
+ }
266
+ function pluralForm(count) {
267
+ const locale = activeLocale();
268
+ if (locale === "ru") {
269
+ const mod10 = count % 10;
270
+ const mod100 = count % 100;
271
+ if (mod10 === 1 && mod100 !== 11) return "one";
272
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
273
+ return "many";
274
+ }
275
+ if (locale === "en") return count === 1 ? "one" : "other";
276
+ return "other";
277
+ }
278
+ /**
279
+ * Translate a `select-quote` key. A numeric `count` selects a plural form and
280
+ * the matching `key.form` entry is used, falling back to `key.other`.
281
+ */
282
+ function t(key, params = {}) {
283
+ if (!api) return key;
284
+ if (typeof params.count === "number") {
285
+ const form = pluralForm(params.count);
286
+ const exact = api.translate(`${key}.${form}`, params);
287
+ if (exact !== `${key}.${form}`) return exact;
288
+ const other = api.translate(`${key}.other`, params);
289
+ if (other !== `${key}.other`) return other;
290
+ }
291
+ return api.translate(key, params);
292
+ }
293
+ /**
294
+ * Subscribe to language or dictionary changes. Components go through
295
+ * `useLocale()` instead; this is the non-React entry point (DOM decorations).
296
+ * Subscribing before the host service is bound is a no-op, so callers subscribe
297
+ * from `apply()`, after `bindLocale()`.
298
+ */
299
+ function subscribeLocale(listener) {
300
+ return api ? api.subscribe(listener) : () => {};
301
+ }
302
+ /** Monotonic host revision counter, bumped on locale and dictionary changes. */
303
+ function localeRevision() {
304
+ return api?.getRevision() ?? 0;
305
+ }
306
+ //#endregion
67
307
  //#region src/client/styles.ts
68
308
  /**
69
309
  * Every style the plugin ships, in four layers.
@@ -1593,7 +1833,7 @@ function placeBadges(anchors, bounds = {}) {
1593
1833
  return anchors.map((anchor) => {
1594
1834
  for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
1595
1835
  const dx = column * STEP_X;
1596
- const dy = -row * STEP_Y;
1836
+ const dy = row === 0 ? 0 : -row * STEP_Y;
1597
1837
  if (!fits(anchor, dx, dy, bounds)) continue;
1598
1838
  const candidate = {
1599
1839
  ...anchor,
@@ -1782,11 +2022,25 @@ function isMarked(pieces) {
1782
2022
  if (!first) return false;
1783
2023
  return first.entry.node.parentElement?.closest(`[${MARK_ATTR}]`) != null;
1784
2024
  }
2025
+ function markLabel(index) {
2026
+ return t("mark.previewAria", { index });
2027
+ }
2028
+ /** The badge fragment is the keyboard entry into that annotation's preview. */
2029
+ function makeBadgeInteractive(mark, index) {
2030
+ mark.tabIndex = 0;
2031
+ mark.setAttribute("role", "button");
2032
+ mark.setAttribute("aria-haspopup", "dialog");
2033
+ const label = markLabel(index);
2034
+ if (mark.getAttribute("aria-label") !== label) mark.setAttribute("aria-label", label);
2035
+ }
1785
2036
  function createMark(job, withIndex) {
1786
2037
  const mark = document.createElement("span");
1787
2038
  mark.className = styles.annMark;
1788
2039
  mark.setAttribute(MARK_ATTR, job.markId);
1789
- if (withIndex) mark.setAttribute(INDEX_ATTR, String(job.index));
2040
+ if (withIndex) {
2041
+ mark.setAttribute(INDEX_ATTR, String(job.index));
2042
+ makeBadgeInteractive(mark, job.index);
2043
+ }
1790
2044
  return mark;
1791
2045
  }
1792
2046
  /**
@@ -1863,8 +2117,17 @@ function refreshMarkIndex(item, job) {
1863
2117
  const last = marks[marks.length - 1];
1864
2118
  const next = String(job.index);
1865
2119
  if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
2120
+ makeBadgeInteractive(last, job.index);
1866
2121
  return true;
1867
2122
  }
2123
+ /** Refresh the localized labels after a language switch (marks survive renders). */
2124
+ function refreshAnnotationMarkLabels() {
2125
+ if (typeof document === "undefined") return;
2126
+ for (const mark of document.querySelectorAll(`[${INDEX_ATTR}]`)) {
2127
+ const index = Number(mark.getAttribute(INDEX_ATTR) ?? 0);
2128
+ if (index > 0) makeBadgeInteractive(mark, index);
2129
+ }
2130
+ }
1868
2131
  /**
1869
2132
  * Resolve overlapping ordinal badges. Two annotations can end at the same
1870
2133
  * offset (nested ranges), which puts their pseudo-element badges on the same
@@ -2479,385 +2742,148 @@ function bindAnnotationSession(sessionId) {
2479
2742
  if (!sessionId || sessionId === runtime.sessionId) return;
2480
2743
  setRuntimeSession(sessionId);
2481
2744
  queueMicrotask(() => adoptPersistedAnnotations(sessionId));
2482
- }
2483
- /**
2484
- * Adopt persisted annotations for a session.
2485
- *
2486
- * In-memory entries win on conflict — they can only be newer, because anything
2487
- * added after the bind is persisted immediately. Annotations parked in the
2488
- * memory-only bucket (added before the bind) move over as well.
2489
- */
2490
- function adoptPersistedAnnotations(sessionId) {
2491
- if (!persistence) return;
2492
- const stored = persistence.load(sessionId);
2493
- const current = bySession.get(sessionId) ?? EMPTY$1;
2494
- const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
2495
- const merged = /* @__PURE__ */ new Map();
2496
- for (const item of stored) merged.set(item.id, item);
2497
- for (const item of [...current, ...orphaned]) merged.set(item.id, item);
2498
- const next = [...merged.values()];
2499
- if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
2500
- bySession.set(sessionId, next);
2501
- if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
2502
- notify();
2503
- }
2504
- /** Same ids in the same order, with the only mutable field (comment) equal. */
2505
- function sameAnnotations(a, b) {
2506
- return a.length === b.length && a.every((item, i) => {
2507
- const other = b[i];
2508
- return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2509
- });
2510
- }
2511
- /**
2512
- * Re-read a session after another tab wrote its storage key.
2513
- *
2514
- * Storage is the shared source of truth: every local mutation persists
2515
- * synchronously, so this tab's memory can only be stale relative to it. The
2516
- * stored array replaces the local one (last writer wins) — merging would
2517
- * resurrect annotations the other tab already sent and cleared.
2518
- *
2519
- * `null` is the `localStorage.clear()` event: reload every partition this tab
2520
- * holds. Sessions that are neither cached nor active are ignored; binding the
2521
- * session later reads storage anyway.
2522
- */
2523
- function adoptStorageAnnotations(sessionId) {
2524
- if (!persistence) return;
2525
- if (sessionId === null) {
2526
- let changed = false;
2527
- for (const key of [...bySession.keys()]) {
2528
- if (key === MEMORY_KEY) continue;
2529
- const stored = persistence.load(key);
2530
- const current = bySession.get(key);
2531
- if (stored.length === 0) {
2532
- bySession.delete(key);
2533
- changed = true;
2534
- } else if (current === void 0 || !sameAnnotations(stored, current)) {
2535
- bySession.set(key, [...stored]);
2536
- changed = true;
2537
- }
2538
- }
2539
- if (changed) notify();
2540
- return;
2541
- }
2542
- if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2543
- const stored = persistence.load(sessionId);
2544
- if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2545
- if (stored.length === 0) bySession.delete(sessionId);
2546
- else bySession.set(sessionId, [...stored]);
2547
- notify();
2548
- }
2549
- function annotationTitle(annotation) {
2550
- return previewTitle(annotation.text);
2551
- }
2552
- //#endregion
2553
- //#region src/client/dom/popoverPlacement.ts
2554
- const GAP$2 = 8;
2555
- const MARGIN = 8;
2556
- /**
2557
- * Place `panel` near `anchor`, flipping vertical side when the preferred side
2558
- * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2559
- */
2560
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2561
- const box = bounds ?? {
2562
- top: 0,
2563
- left: 0,
2564
- right: window.innerWidth,
2565
- bottom: window.innerHeight
2566
- };
2567
- const spaceBelow = box.bottom - anchor.bottom;
2568
- const spaceAbove = anchor.top - box.top;
2569
- let side = preferred;
2570
- const need = panel.height + gap;
2571
- if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
2572
- else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
2573
- return {
2574
- top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
2575
- left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
2576
- side
2577
- };
2578
- }
2579
- //#endregion
2580
- //#region src/client/ui/icons.tsx
2581
- const SVG_ATTRS = {
2582
- xmlns: "http://www.w3.org/2000/svg",
2583
- viewBox: "0 0 24 24",
2584
- fill: "none",
2585
- stroke: "currentColor",
2586
- strokeWidth: 2,
2587
- strokeLinecap: "round",
2588
- strokeLinejoin: "round",
2589
- "aria-hidden": true,
2590
- focusable: false
2591
- };
2592
- const SHAPES = {
2593
- copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
2594
- width: 14,
2595
- height: 14,
2596
- x: 8,
2597
- y: 8,
2598
- rx: 2,
2599
- ry: 2
2600
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
2601
- pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
2602
- "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2603
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
2604
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
2605
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
2606
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2607
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
2608
- ] }),
2609
- x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
2610
- plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
2611
- "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2612
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
2613
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
2614
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
2615
- ] })
2616
- };
2617
- function LucideIcon({ name, size = 15 }) {
2618
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2619
- ...SVG_ATTRS,
2620
- width: size,
2621
- height: size,
2622
- children: SHAPES[name]
2623
- });
2624
- }
2625
- //#endregion
2626
- //#region src/client/i18n.ts
2627
- /**
2628
- * Copy for dsh-select-quote, served through the host locale service.
2629
- *
2630
- * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
2631
- * General → Language, persisted as `locale.preference`). This module owns only
2632
- * the dictionaries and the plural form selection that the host's plain-template
2633
- * lookup cannot express; it never resolves a language on its own.
2634
- *
2635
- * NOT localized on purpose:
2636
- * - wire markers (`> [选中文本]`, `<response-annotations>`,
2637
- * `:dsh-annotation{index="N"}`) — they are the message format the model
2638
- * and the transcript parser agree on;
2639
- * - the `# Response annotations:` header — model-facing instruction prose,
2640
- * not UI text.
2641
- */
2642
- const LOCALE_NS = "select-quote";
2643
- const zh = {
2644
- "common.selectedText": "选中的文本",
2645
- "common.commentPlaceholder": "添加可选评论…",
2646
- "common.cancel": "取消",
2647
- "common.close": "关闭",
2648
- "common.save": "保存",
2649
- "toolbar.aria": "划词操作",
2650
- "toolbar.commentHint": "添加批注…",
2651
- "toolbar.copy": "复制",
2652
- "toolbar.openLink": "打开链接",
2653
- "toolbar.copyFailed": "复制失败",
2654
- "toolbar.addToTask": "添加到任务",
2655
- "toolbar.commentDialogAria": "添加可选评论",
2656
- "toolbar.skipComment": "暂不评论",
2657
- "toolbar.addComment": "添加评论",
2658
- "menu.aria": "划词菜单",
2659
- "menu.shortcutLabel": "打开划词菜单",
2660
- "menu.addShortcutLabel": "将选中文字添加到任务",
2661
- "menu.noSelection": "先在对话里选中一段文字",
2662
- "menu.busy": "先完成当前批注",
2663
- "summary.hint": "悬停查看批注",
2664
- "summary.hintTouch": "点按查看批注",
2665
- "summary.dialogAria": "{count} 条划词批注详情",
2666
- "summary.edit": "编辑批注 {index}",
2667
- "summary.remove": "移除批注 {index}",
2668
- "summary.userComment": "用户评论",
2669
- "summary.noComment": "未添加评论",
2670
- "summary.aria.other": "{count} 条划词批注。展开查看详情。",
2671
- "summary.title.other": "{count} 条批注",
2672
- "summary.removeAll": "移除全部划词批注",
2673
- "chip.annotation": "批注 {index}",
2674
- "chip.previewAria": "批注预览",
2675
- "chip.jump": "原文",
2676
- "chip.jumpAria": "跳转到原文",
2677
- "settings.trigger": "划词入口",
2678
- "settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
2679
- "settings.trigger.auto": "自动浮出",
2680
- "settings.trigger.contextmenu": "右键菜单",
2681
- "settings.trigger.both": "两者都开",
2682
- "settings.shortcut": "快捷键",
2683
- "settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
2684
- "settings.shortcutListAria": "当前快捷键",
2685
- "settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
2686
- "settings.shortcutUnbound": "未绑定",
2687
- "settings.shortcutConflict": "与其它快捷键冲突"
2688
- };
2689
- const en = {
2690
- "common.selectedText": "Selected text",
2691
- "common.commentPlaceholder": "Add an optional comment…",
2692
- "common.cancel": "Cancel",
2693
- "common.close": "Close",
2694
- "common.save": "Save",
2695
- "toolbar.aria": "Selection actions",
2696
- "toolbar.commentHint": "Add annotation…",
2697
- "toolbar.copy": "Copy",
2698
- "toolbar.openLink": "Open link",
2699
- "toolbar.copyFailed": "Copy failed",
2700
- "toolbar.addToTask": "Add to task",
2701
- "toolbar.commentDialogAria": "Add an optional comment",
2702
- "toolbar.skipComment": "Skip comment",
2703
- "toolbar.addComment": "Add comment",
2704
- "menu.aria": "Selection menu",
2705
- "menu.shortcutLabel": "Open selection menu",
2706
- "menu.addShortcutLabel": "Add selected text to task",
2707
- "menu.noSelection": "Select text in the conversation first",
2708
- "menu.busy": "Finish the current annotation first",
2709
- "summary.hint": "Hover to view annotations",
2710
- "summary.hintTouch": "Tap to view annotations",
2711
- "summary.dialogAria": "Annotation details: {count}",
2712
- "summary.edit": "Edit annotation {index}",
2713
- "summary.remove": "Remove annotation {index}",
2714
- "summary.userComment": "User comment",
2715
- "summary.noComment": "No comment added",
2716
- "summary.aria.one": "{count} annotation. Open to view details.",
2717
- "summary.aria.other": "{count} annotations. Open to view details.",
2718
- "summary.title.one": "{count} annotation",
2719
- "summary.title.other": "{count} annotations",
2720
- "summary.removeAll": "Remove all annotations",
2721
- "chip.annotation": "Annotation {index}",
2722
- "chip.previewAria": "Annotation preview",
2723
- "chip.jump": "Original",
2724
- "chip.jumpAria": "Jump to original",
2725
- "settings.trigger": "Trigger",
2726
- "settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
2727
- "settings.trigger.auto": "Automatic",
2728
- "settings.trigger.contextmenu": "Right-click",
2729
- "settings.trigger.both": "Both",
2730
- "settings.shortcut": "Shortcut",
2731
- "settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
2732
- "settings.shortcutListAria": "Active shortcuts",
2733
- "settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
2734
- "settings.shortcutUnbound": "Unbound",
2735
- "settings.shortcutConflict": "Conflicts with another shortcut"
2736
- };
2737
- /**
2738
- * Russian is not a shipped host locale: it is added through
2739
- * `ctx.locale.addLanguage()` and its dictionary through the three-argument
2740
- * `register()`. Its fallback chain ends at `en`, as the host requires.
2741
- */
2742
- const ru = {
2743
- "common.selectedText": "Выделенный текст",
2744
- "common.commentPlaceholder": "Добавить необязательный комментарий…",
2745
- "common.cancel": "Отмена",
2746
- "common.close": "Закрыть",
2747
- "common.save": "Сохранить",
2748
- "toolbar.aria": "Действия с выделенным текстом",
2749
- "toolbar.commentHint": "Добавить аннотацию…",
2750
- "toolbar.copy": "Копировать",
2751
- "toolbar.openLink": "Открыть ссылку",
2752
- "toolbar.copyFailed": "Не удалось скопировать",
2753
- "toolbar.addToTask": "Добавить к задаче",
2754
- "toolbar.commentDialogAria": "Добавить необязательный комментарий",
2755
- "toolbar.skipComment": "Без комментария",
2756
- "toolbar.addComment": "Добавить комментарий",
2757
- "menu.aria": "Меню выделения",
2758
- "menu.shortcutLabel": "Открыть меню выделения",
2759
- "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
2760
- "menu.noSelection": "Сначала выделите текст в диалоге",
2761
- "menu.busy": "Сначала завершите текущую аннотацию",
2762
- "summary.hint": "Наведите, чтобы увидеть аннотации",
2763
- "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
2764
- "summary.dialogAria": "Детали аннотаций: {count}",
2765
- "summary.edit": "Изменить аннотацию {index}",
2766
- "summary.remove": "Удалить аннотацию {index}",
2767
- "summary.userComment": "Комментарий пользователя",
2768
- "summary.noComment": "Комментарий не добавлен",
2769
- "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2770
- "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2771
- "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2772
- "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2773
- "summary.title.one": "{count} аннотация",
2774
- "summary.title.few": "{count} аннотации",
2775
- "summary.title.many": "{count} аннотаций",
2776
- "summary.title.other": "{count} аннотаций",
2777
- "summary.removeAll": "Удалить все аннотации",
2778
- "chip.annotation": "Аннотация {index}",
2779
- "chip.previewAria": "Просмотр аннотации",
2780
- "chip.jump": "Оригинал",
2781
- "chip.jumpAria": "Перейти к оригиналу",
2782
- "settings.trigger": "Триггер",
2783
- "settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
2784
- "settings.trigger.auto": "Автоматически",
2785
- "settings.trigger.contextmenu": "По правому клику",
2786
- "settings.trigger.both": "Оба способа",
2787
- "settings.shortcut": "Горячая клавиша",
2788
- "settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
2789
- "settings.shortcutListAria": "Текущие сочетания",
2790
- "settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
2791
- "settings.shortcutUnbound": "Не назначено",
2792
- "settings.shortcutConflict": "Конфликтует с другим сочетанием"
2793
- };
2794
- /** Both shipped host locales, required together by the typed `register()`. */
2795
- const SHIPPED_DICTS = Object.freeze({
2796
- zh,
2797
- en
2798
- });
2799
- /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
2800
- const DICTIONARIES = Object.freeze({
2801
- zh,
2802
- en,
2803
- ru
2804
- });
2805
- const LANGUAGE_RU = Object.freeze({
2806
- id: "ru",
2807
- label: "Русский",
2808
- fallback: "en"
2809
- });
2810
- let api;
2745
+ }
2811
2746
  /**
2812
- * Install the host-bound translation entry point. Called once from `apply()`
2813
- * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
2747
+ * Adopt persisted annotations for a session.
2748
+ *
2749
+ * In-memory entries win on conflict — they can only be newer, because anything
2750
+ * added after the bind is persisted immediately. Annotations parked in the
2751
+ * memory-only bucket (added before the bind) move over as well.
2814
2752
  */
2815
- function bindLocale(next) {
2816
- api = next;
2817
- }
2818
- /** Active language id, or `en` before the host service is bound. */
2819
- function activeLocale() {
2820
- return api?.getActive() ?? "en";
2753
+ function adoptPersistedAnnotations(sessionId) {
2754
+ if (!persistence) return;
2755
+ const stored = persistence.load(sessionId);
2756
+ const current = bySession.get(sessionId) ?? EMPTY$1;
2757
+ const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
2758
+ const merged = /* @__PURE__ */ new Map();
2759
+ for (const item of stored) merged.set(item.id, item);
2760
+ for (const item of [...current, ...orphaned]) merged.set(item.id, item);
2761
+ const next = [...merged.values()];
2762
+ if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
2763
+ bySession.set(sessionId, next);
2764
+ if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
2765
+ notify();
2821
2766
  }
2822
- function pluralForm(count) {
2823
- const locale = activeLocale();
2824
- if (locale === "ru") {
2825
- const mod10 = count % 10;
2826
- const mod100 = count % 100;
2827
- if (mod10 === 1 && mod100 !== 11) return "one";
2828
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
2829
- return "many";
2830
- }
2831
- if (locale === "en") return count === 1 ? "one" : "other";
2832
- return "other";
2767
+ /** Same ids in the same order, with the only mutable field (comment) equal. */
2768
+ function sameAnnotations(a, b) {
2769
+ return a.length === b.length && a.every((item, i) => {
2770
+ const other = b[i];
2771
+ return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2772
+ });
2833
2773
  }
2834
2774
  /**
2835
- * Translate a `select-quote` key. A numeric `count` selects a plural form and
2836
- * the matching `key.form` entry is used, falling back to `key.other`.
2775
+ * Re-read a session after another tab wrote its storage key.
2776
+ *
2777
+ * Storage is the shared source of truth: every local mutation persists
2778
+ * synchronously, so this tab's memory can only be stale relative to it. The
2779
+ * stored array replaces the local one (last writer wins) — merging would
2780
+ * resurrect annotations the other tab already sent and cleared.
2781
+ *
2782
+ * `null` is the `localStorage.clear()` event: reload every partition this tab
2783
+ * holds. Sessions that are neither cached nor active are ignored; binding the
2784
+ * session later reads storage anyway.
2837
2785
  */
2838
- function t(key, params = {}) {
2839
- if (!api) return key;
2840
- if (typeof params.count === "number") {
2841
- const form = pluralForm(params.count);
2842
- const exact = api.translate(`${key}.${form}`, params);
2843
- if (exact !== `${key}.${form}`) return exact;
2844
- const other = api.translate(`${key}.other`, params);
2845
- if (other !== `${key}.other`) return other;
2786
+ function adoptStorageAnnotations(sessionId) {
2787
+ if (!persistence) return;
2788
+ if (sessionId === null) {
2789
+ let changed = false;
2790
+ for (const key of [...bySession.keys()]) {
2791
+ if (key === MEMORY_KEY) continue;
2792
+ const stored = persistence.load(key);
2793
+ const current = bySession.get(key);
2794
+ if (stored.length === 0) {
2795
+ bySession.delete(key);
2796
+ changed = true;
2797
+ } else if (current === void 0 || !sameAnnotations(stored, current)) {
2798
+ bySession.set(key, [...stored]);
2799
+ changed = true;
2800
+ }
2801
+ }
2802
+ if (changed) notify();
2803
+ return;
2846
2804
  }
2847
- return api.translate(key, params);
2805
+ if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2806
+ const stored = persistence.load(sessionId);
2807
+ if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2808
+ if (stored.length === 0) bySession.delete(sessionId);
2809
+ else bySession.set(sessionId, [...stored]);
2810
+ notify();
2811
+ }
2812
+ function annotationTitle(annotation) {
2813
+ return previewTitle(annotation.text);
2848
2814
  }
2815
+ //#endregion
2816
+ //#region src/client/dom/popoverPlacement.ts
2817
+ const GAP$2 = 8;
2818
+ const MARGIN = 8;
2849
2819
  /**
2850
- * Subscribe to language or dictionary changes. Components go through
2851
- * `useLocale()` instead; this is the non-React entry point (DOM decorations).
2852
- * Subscribing before the host service is bound is a no-op, so callers subscribe
2853
- * from `apply()`, after `bindLocale()`.
2820
+ * Place `panel` near `anchor`, flipping vertical side when the preferred side
2821
+ * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2854
2822
  */
2855
- function subscribeLocale(listener) {
2856
- return api ? api.subscribe(listener) : () => {};
2823
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2824
+ const box = bounds ?? {
2825
+ top: 0,
2826
+ left: 0,
2827
+ right: window.innerWidth,
2828
+ bottom: window.innerHeight
2829
+ };
2830
+ const spaceBelow = box.bottom - anchor.bottom;
2831
+ const spaceAbove = anchor.top - box.top;
2832
+ let side = preferred;
2833
+ const need = panel.height + gap;
2834
+ if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
2835
+ else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
2836
+ return {
2837
+ top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
2838
+ left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
2839
+ side
2840
+ };
2857
2841
  }
2858
- /** Monotonic host revision counter, bumped on locale and dictionary changes. */
2859
- function localeRevision() {
2860
- return api?.getRevision() ?? 0;
2842
+ //#endregion
2843
+ //#region src/client/ui/icons.tsx
2844
+ const SVG_ATTRS = {
2845
+ xmlns: "http://www.w3.org/2000/svg",
2846
+ viewBox: "0 0 24 24",
2847
+ fill: "none",
2848
+ stroke: "currentColor",
2849
+ strokeWidth: 2,
2850
+ strokeLinecap: "round",
2851
+ strokeLinejoin: "round",
2852
+ "aria-hidden": true,
2853
+ focusable: false
2854
+ };
2855
+ const SHAPES = {
2856
+ copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
2857
+ width: 14,
2858
+ height: 14,
2859
+ x: 8,
2860
+ y: 8,
2861
+ rx: 2,
2862
+ ry: 2
2863
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
2864
+ pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
2865
+ "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
2867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
2868
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
2869
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2870
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
2871
+ ] }),
2872
+ x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
2873
+ plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
2874
+ "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2875
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
2876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
2877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
2878
+ ] })
2879
+ };
2880
+ function LucideIcon({ name, size = 15 }) {
2881
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2882
+ ...SVG_ATTRS,
2883
+ width: size,
2884
+ height: size,
2885
+ children: SHAPES[name]
2886
+ });
2861
2887
  }
2862
2888
  //#endregion
2863
2889
  //#region src/client/useLocale.ts
@@ -5293,6 +5319,7 @@ let onScroll = null;
5293
5319
  let onResize = null;
5294
5320
  let leavingBox = null;
5295
5321
  let leavingTimer = 0;
5322
+ let markBindingCleanup = null;
5296
5323
  function cancelTimers() {
5297
5324
  if (showTimer !== 0) window.clearTimeout(showTimer);
5298
5325
  if (hideTimer !== 0) window.clearTimeout(hideTimer);
@@ -5453,12 +5480,14 @@ function jumpToMark(event) {
5453
5480
  }
5454
5481
  if (text) scrollToAnnotation(index, text);
5455
5482
  }
5456
- function buildPreview(index, quoteText, comment) {
5483
+ function buildPreview(index, quoteText, comment, withJump = true) {
5457
5484
  const box = document.createElement("div");
5458
5485
  box.className = cx(A.col, styles.chipPreview);
5459
5486
  box.setAttribute("data-dsq-chip-preview", "true");
5460
5487
  box.setAttribute("role", "dialog");
5461
5488
  box.setAttribute("aria-label", t("chip.previewAria"));
5489
+ box.tabIndex = -1;
5490
+ jumpButton = null;
5462
5491
  const body = document.createElement("div");
5463
5492
  body.className = styles.chipPreviewBody;
5464
5493
  body.style.animation = "none";
@@ -5467,18 +5496,21 @@ function buildPreview(index, quoteText, comment) {
5467
5496
  const title = document.createElement("span");
5468
5497
  title.className = styles.chipPreviewTitle;
5469
5498
  title.textContent = t("chip.annotation", { index });
5470
- jumpButton = document.createElement("button");
5471
- jumpButton.type = "button";
5472
- jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5473
- jumpButton.setAttribute("data-dsq-chip-jump", "true");
5474
- jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5475
- jumpButton.title = t("chip.jumpAria");
5476
- jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5477
- const jumpLabel = document.createElement("span");
5478
- jumpLabel.textContent = t("chip.jump");
5479
- jumpButton.append(jumpLabel);
5480
- jumpButton.addEventListener("click", jumpToMark);
5481
- head.append(title, jumpButton);
5499
+ head.append(title);
5500
+ if (withJump) {
5501
+ jumpButton = document.createElement("button");
5502
+ jumpButton.type = "button";
5503
+ jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5504
+ jumpButton.setAttribute("data-dsq-chip-jump", "true");
5505
+ jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5506
+ jumpButton.title = t("chip.jumpAria");
5507
+ jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5508
+ const jumpLabel = document.createElement("span");
5509
+ jumpLabel.textContent = t("chip.jump");
5510
+ jumpButton.append(jumpLabel);
5511
+ jumpButton.addEventListener("click", jumpToMark);
5512
+ head.append(jumpButton);
5513
+ }
5482
5514
  body.append(head);
5483
5515
  appendLabel(body, t("common.selectedText"));
5484
5516
  appendText(body, styles.chipPreviewQuote, quoteText);
@@ -5505,6 +5537,39 @@ function buildPreview(index, quoteText, comment) {
5505
5537
  });
5506
5538
  return box;
5507
5539
  }
5540
+ /** Show and watch one preview; `activeChip` is the element it belongs to. */
5541
+ function mountPreview(box, anchor) {
5542
+ preview = box;
5543
+ box.style.visibility = "hidden";
5544
+ document.body.append(box);
5545
+ const { top, left } = placePopover(anchor, {
5546
+ width: box.offsetWidth || 320,
5547
+ height: box.offsetHeight || 180
5548
+ }, "top", GAP);
5549
+ box.style.top = `${top}px`;
5550
+ box.style.left = `${left}px`;
5551
+ box.style.visibility = "";
5552
+ const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5553
+ if (animated) animated.style.animation = "";
5554
+ onDocPointerDown = (event) => {
5555
+ const target = event.target;
5556
+ if (!(target instanceof Node)) return;
5557
+ if (box.contains(target) || activeChip?.contains(target)) return;
5558
+ closeChipPreview(false);
5559
+ };
5560
+ document.addEventListener("pointerdown", onDocPointerDown, true);
5561
+ onScroll = scheduleReposition;
5562
+ document.addEventListener("scroll", onScroll, true);
5563
+ onResize = scheduleReposition;
5564
+ window.addEventListener("resize", onResize);
5565
+ observer = new MutationObserver(() => {
5566
+ if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5567
+ });
5568
+ observer.observe(document.body, {
5569
+ childList: true,
5570
+ subtree: true
5571
+ });
5572
+ }
5508
5573
  function showChipPreview(chip, index, nextPinned) {
5509
5574
  cancelTimers();
5510
5575
  if (!chip.isConnected) return;
@@ -5538,38 +5603,105 @@ function showChipPreview(chip, index, nextPinned) {
5538
5603
  activeText = text;
5539
5604
  activeIndex = index;
5540
5605
  pinned = nextPinned;
5541
- const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
5542
- preview = box;
5543
- box.style.visibility = "hidden";
5544
- document.body.append(box);
5545
- const panel = {
5546
- width: box.offsetWidth || 320,
5547
- height: box.offsetHeight || 180
5606
+ mountPreview(buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null), chip.getBoundingClientRect());
5607
+ }
5608
+ /**
5609
+ * Resolve the annotation behind a transcript mark. Durable marks carry a
5610
+ * `dqsm_` id and read their quote from the user turn's registry; pending marks
5611
+ * carry the store id and read the live list, so a comment edited after
5612
+ * decoration still shows up.
5613
+ */
5614
+ function annotationOfMark(mark) {
5615
+ const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
5616
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5617
+ if (index <= 0) return null;
5618
+ if (id.startsWith("dqsm_")) {
5619
+ const ownerKey = quotesOwnerOf(mark);
5620
+ const quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
5621
+ if (!ownerKey || !quote) return null;
5622
+ const text = quote.text.trim();
5623
+ return text ? {
5624
+ text,
5625
+ comment: (quote.comment ?? "").trim(),
5626
+ ownerKey,
5627
+ messageId: quote.messageId ?? null
5628
+ } : null;
5629
+ }
5630
+ const item = getAnnotations().find((annotation) => annotation.id === id);
5631
+ if (!item) return null;
5632
+ const text = item.text.trim();
5633
+ return text ? {
5634
+ text,
5635
+ comment: (item.comment ?? "").trim(),
5636
+ ownerKey: null,
5637
+ messageId: item.sources[0]?.messageId ?? null
5638
+ } : null;
5639
+ }
5640
+ function showMarkPreview(mark, focus) {
5641
+ if (!mark.isConnected) return;
5642
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5643
+ if (index <= 0) return;
5644
+ if (activeChip === mark && preview) {
5645
+ pinned = true;
5646
+ return;
5647
+ }
5648
+ const data = annotationOfMark(mark);
5649
+ if (!data) return;
5650
+ cancelTimers();
5651
+ closeChipPreview(false);
5652
+ flushLeavingPreview();
5653
+ activeChip = mark;
5654
+ activeMark = mark;
5655
+ activeOwnerKey = data.ownerKey;
5656
+ activeMessageId = data.messageId;
5657
+ activeText = data.text;
5658
+ activeIndex = index;
5659
+ pinned = true;
5660
+ const box = buildPreview(index, data.text, data.comment, false);
5661
+ mountPreview(box, mark.getBoundingClientRect());
5662
+ if (focus) box.focus({ preventScroll: true });
5663
+ }
5664
+ /**
5665
+ * Click/keyboard previews for the ordinal badge fragments in transcript
5666
+ * marks. Delegated on the document: marks are replaced by host re-renders and
5667
+ * binding every fragment would leak listeners across those passes.
5668
+ */
5669
+ function bindAnnotationMarkPreview() {
5670
+ if (typeof document === "undefined") return () => {};
5671
+ if (markBindingCleanup) return markBindingCleanup;
5672
+ const markOf = (target) => {
5673
+ if (!(target instanceof Element)) return null;
5674
+ return target.closest("[data-dsq-ann-mark]");
5548
5675
  };
5549
- const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
5550
- box.style.top = `${top}px`;
5551
- box.style.left = `${left}px`;
5552
- box.style.visibility = "";
5553
- const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5554
- if (animated) animated.style.animation = "";
5555
- onDocPointerDown = (event) => {
5556
- const target = event.target;
5557
- if (!(target instanceof Node)) return;
5558
- if (box.contains(target) || activeChip?.contains(target)) return;
5559
- closeChipPreview(false);
5676
+ const onClick = (event) => {
5677
+ const mark = markOf(event.target);
5678
+ if (!mark) return;
5679
+ const selection = window.getSelection();
5680
+ if (selection && !selection.isCollapsed) return;
5681
+ showMarkPreview(mark, false);
5560
5682
  };
5561
- document.addEventListener("pointerdown", onDocPointerDown, true);
5562
- onScroll = scheduleReposition;
5563
- document.addEventListener("scroll", onScroll, true);
5564
- onResize = scheduleReposition;
5565
- window.addEventListener("resize", onResize);
5566
- observer = new MutationObserver(() => {
5567
- if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5568
- });
5569
- observer.observe(document.body, {
5570
- childList: true,
5571
- subtree: true
5572
- });
5683
+ const onKeyDown = (event) => {
5684
+ const mark = markOf(event.target);
5685
+ if (!mark) return;
5686
+ if (event.key === "Escape" && preview !== null) {
5687
+ event.preventDefault();
5688
+ closeChipPreview(true);
5689
+ return;
5690
+ }
5691
+ if ((event.key === "Enter" || event.key === " ") && mark.hasAttribute("data-dsq-ann-index")) {
5692
+ event.preventDefault();
5693
+ showMarkPreview(mark, true);
5694
+ }
5695
+ };
5696
+ document.addEventListener("click", onClick);
5697
+ document.addEventListener("keydown", onKeyDown);
5698
+ const cleanup = () => {
5699
+ document.removeEventListener("click", onClick);
5700
+ document.removeEventListener("keydown", onKeyDown);
5701
+ markBindingCleanup = null;
5702
+ };
5703
+ markBindingCleanup = cleanup;
5704
+ return cleanup;
5573
5705
  }
5574
5706
  /** Close the preview. `restoreFocus` is for a deliberate Escape. */
5575
5707
  function closeChipPreview(restoreFocus) {
@@ -6253,7 +6385,10 @@ function apply(ctx) {
6253
6385
  getRevision: () => ctx.locale.getSnapshot().revision,
6254
6386
  subscribe: (listener) => ctx.locale.subscribe(listener)
6255
6387
  });
6256
- ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
6388
+ ctx.effect(() => subscribeLocale(() => {
6389
+ refreshDirectiveChips();
6390
+ refreshAnnotationMarkLabels();
6391
+ }), "dsh-select-quote: chip language");
6257
6392
  ctx.inject(["configForms"], (scope) => {
6258
6393
  scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
6259
6394
  const form = scope.configForms.get(ROW_ID);
@@ -6269,6 +6404,7 @@ function apply(ctx) {
6269
6404
  });
6270
6405
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
6271
6406
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
6407
+ ctx.effect(() => bindAnnotationMarkPreview(), "dsh-select-quote: mark preview");
6272
6408
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
6273
6409
  ctx.effect(() => {
6274
6410
  syncPendingAnnotationJobs(getAnnotations());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-select-quote",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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",
@@ -44,8 +44,9 @@
44
44
  "sync:desktop": "node scripts/sync-desktop.mjs",
45
45
  "dev:desktop": "node scripts/dev-desktop.mjs",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "test": "vitest run",
47
48
  "verify": "node scripts/verify-bundle.mjs",
48
- "check": "pnpm run typecheck && pnpm run build && pnpm run verify",
49
+ "check": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run verify",
49
50
  "release": "pnpm run check && commit-and-tag-version"
50
51
  },
51
52
  "dsh": {
@@ -83,6 +84,7 @@
83
84
  "react": "^18.2.0",
84
85
  "react-dom": "^18.2.0",
85
86
  "tsdown": "0.23.0",
86
- "typescript": "^5.8.0"
87
+ "typescript": "^5.8.0",
88
+ "vitest": "^5.0.5"
87
89
  }
88
90
  }