dsh-sidecard-ask 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0
4
+
5
+ **主题:P0 交互提效——一键快捷动作、自定义动作模板、改问重试、代码块复制。**
6
+
7
+ - **一键快捷动作条(划词主路径 4 步 → 1 步)**:选中文字后,触发按钮旁直接出现
8
+ 「解释 / 翻译 / 总结 / 润色 / 纠错」五个动作芯片,点击即跳过提问框、以预置问题
9
+ 直接在侧边卡片发起作答;提问框顶部也有同一排芯片(`Alt+Q` 与"仅快捷键"用户同样可达)。
10
+ 芯片 `title` 悬停展示完整问题指令与快捷键。
11
+ - **自定义动作模板**:新增配置项 `quickActions`(三层合并、随用户层持久化)。
12
+ 设置页「快捷动作」区可启用/停用内置动作、为每个动作配独立快捷键、新增/删除自定义动作
13
+ (名称 + 问题指令 + 快捷键),问题指令支持 `{selection}` 占位符(内嵌选中正文;
14
+ 不写占位符时原文按既有方式随问题附上)。内置 5 个动作的文案在客户端按界面语言解析,
15
+ 配置与线协议保持语言中立。
16
+ - 宿主端新增**有界结构校验** `sanitizeQuickActions`:≤12 条、id 唯一且 ≤40 字符、
17
+ 自定义动作名称 1–20 字 / 问题 1–500 字、快捷键 ≤40 字符;任何一条非法即**整表拒绝**
18
+ (`/config` 返回 `invalid-config`),不做半表保存。客户端保存前同样预检,
19
+ 并检查动作快捷键之间、动作与主快捷键之间的冲突(大小写不敏感)。
20
+ - 每个动作的快捷键在页面 keydown 捕获阶段分发:主快捷键优先;在插件自己的输入框/编辑器中
21
+ 不拦截任何快捷键;无选区时回退到最近一次触发候选(与主快捷键同语义)。
22
+ - **编辑问题后重新提问(改问重试)**:终态侧边卡片(已完成 / 失败 / 已停止)新增「改问」按钮,
23
+ 就地编辑原问题后**重新生成当前这一轮**(不把旧问答压入 history,区别于"继续追问"的追加一轮);
24
+ 原「重试」保留(原样重发,仅失败/停止态显示);空问题不触发。
25
+ - **代码块单独复制**:答案中的围栏代码块右上角新增复制按钮,复用既有剪贴板 API →
26
+ 选区降级链与 toast 提示;`pre` 改为 `pre-wrap` 容器承载,代码文本与样式不变。
27
+ - 浮层/弹层样式新增 `.dsa-quickbar` / `.dsa-chip` / `.dsa-pre-wrap` / `.dsa-code-copy` /
28
+ `.dsa-qa-*`,并同步加入 Electron 窗口拖拽守卫(`-webkit-app-region:no-drag`)。
29
+
30
+ 本机实测:verify 53 / contract 589 / float-placement 38 / smoke 181 = **861 项全部通过**
31
+ (较 1.6.0 的 577 项新增 284 项;含动作解析/占位符截断/快捷键冲突/宿主整表校验/
32
+ 芯片与触发条渲染/quickAsk 与 retryEdit 端到端/代码块复制按钮/中英词典整表奇偶等用例)。
33
+
34
+ > 未验证(如实记录):浏览器与桌面 Electron 内的视觉观感与真实点按——本工作区无 GUI 自动化
35
+ > 接入运行实例,动作条换行/定位、芯片点击、代码块复制按钮、设置页编辑器需在真实页面按
36
+ > README 第九节路径人工确认;单测覆盖组件树与端到端数据流,替代不了观感验证。
37
+
3
38
  ## 1.6.0
4
39
 
5
40
  **主题:最新版 DSH(0.2.1-alpha.2)适配 + 新旧包名共载时的单例护栏。**
package/README.md CHANGED
@@ -42,7 +42,7 @@
42
42
 
43
43
  | 路径 | 操作 | 期望结果 |
44
44
  |---|---|---|
45
- | **A. 就地追问(侧边卡片)** | 在聊天区选中一句话 → 点浮出的按钮 → 输入问题 → Enter | 右下角浮出卡片,答案逐字流出;卡片底部有「复制 / 继续追问 / 关闭」 |
45
+ | **A. 就地追问(侧边卡片)** | 在聊天区选中一句话 → 点浮出的按钮(或点旁边的「解释/翻译/总结/润色/纠错」芯片一键发起)→ 输入问题 → Enter | 右下角浮出卡片,答案逐字流出;卡片底部有「复制 / 继续追问 / 改问 / 关闭」,代码块右上角可单独复制 |
46
46
  | **B. 主对话追问** | 选中 → 提问框里把「作答位置」切到**主对话** → Enter | 追问以引用块形式出现在主对话,答案由当前会话正常流式输出 |
47
47
  | **C. 快捷键 + 降级** | 选中 → 按 `Alt+Q`(默认) | 直接弹出提问框;若侧边作答引擎不可用,卡片给出错误提示与「改到主对话」按钮 |
48
48
 
@@ -159,6 +159,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
159
159
  | `sideTools` | `readonly` \| `inherit` | `readonly` | 侧边作答者的工具权限:只读白名单 / 继承当前会话 | 下一次提问 |
160
160
  | `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
161
161
  | `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |
162
+ | `quickActions` | 动作数组(≤12 条) | 5 个内置动作 | **一键快捷动作**:选中后按钮旁的动作芯片。条目形如 `{id, enabled, shortcut}`;内置 id 为 `builtin:explain/translate/summarize/polish/fix`,自定义动作(非 `builtin:` 前缀)还需 `label`(≤20 字)与 `question`(≤500 字,可用 `{selection}` 内嵌选中正文)。设置页可视化编辑,用户层整表覆盖默认值 | 立即(快捷键下次按键生效) |
162
163
 
163
164
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
164
165
 
@@ -456,12 +457,16 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
456
457
  ### A. 路径一:就地追问 → 侧边卡片流式作答
457
458
 
458
459
  1. 在聊天区选中一句**非输入框内**的文本(例如助手消息里的半句话);
459
- 2. 选区末端应浮出「💬 追问选中内容」按钮,按钮带区域徽标(聊天区 / 任务区 / 跨区选择);
460
- 3. 点击后按钮消失、提问框出现,焦点在输入框,引文预览显示选中文本;
460
+ 2. 选区末端应浮出动作条:「💬 追问选中内容」按钮 + 「解释 / 翻译 / 总结 / 润色 / 纠错」芯片,
461
+ 按钮带区域徽标(聊天区 / 任务区 / 跨区选择);**点任一芯片即跳过提问框直接在侧边卡片作答**;
462
+ 3. 点主按钮后提问框出现(顶部同样有一排芯片),焦点在输入框,引文预览显示选中文本;
461
463
  4. 输入问题按 Enter → 提问框关闭,右下角出现卡片,状态先为「作答中…」并**逐字增长**;
462
- 5. 结束后状态变「已完成」;底部出现「复制 / 继续追问 / 关闭」;
463
- 6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题,卡片内容**重置换行**后继续流式;
464
+ 5. 结束后状态变「已完成」;底部出现「改问 / 复制 / 继续追问 / 关闭」;答案里代码块的右上角有「⧉ 复制代码」;
465
+ 6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题(追加一轮),卡片内容**重置换行**后继续流式;
466
+ 点「改问」→ 修改当前问题后**重新生成这一轮**(不追加历史);
464
467
  7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。
468
+ 7b. 设置页「快捷动作」区:可停用内置动作、给动作配快捷键(如 `Alt+T`)、添加自定义动作
469
+ (名称 + 问题指令,指令里可写 `{selection}`);配好后选中文字直接按快捷键等价于点芯片。
465
470
  8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
466
471
  展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
467
472
  用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
@@ -510,8 +515,8 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
510
515
  ```
511
516
 
512
517
  四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
513
- 当前规模(1.6.0 本机实测,四项均 exit 0):verify **53** 项 + contract **323** 项 + float-placement **38** 项 +
514
- smoke **163** 项 = **577 项全部通过**。
518
+ 当前规模(1.7.0 本机实测,四项均 exit 0):verify **53** 项 + contract **589** 项 + float-placement **38** 项 +
519
+ smoke **181** 项 = **861 项全部通过**。
515
520
 
516
521
  > 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
517
522
  > 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.6.0'
50
+ const CLIENT_VERSION = '1.7.0'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -98,6 +98,42 @@ window.__ModuleLoader__.load({
98
98
  sideProvider: 'auto',
99
99
  floatMode: 'capsule',
100
100
  floatMaxWidth: 340,
101
+ // Mirrors the Host's DEFAULT_CONFIG.quickActions (contract-asserted):
102
+ // `builtin:*` entries are resolved through QUICK_ACTION_BUILTINS, while
103
+ // custom entries (no builtin prefix) carry their own label/question.
104
+ quickActions: [
105
+ { id: 'builtin:explain', enabled: true, shortcut: '' },
106
+ { id: 'builtin:translate', enabled: true, shortcut: '' },
107
+ { id: 'builtin:summarize', enabled: true, shortcut: '' },
108
+ { id: 'builtin:polish', enabled: true, shortcut: '' },
109
+ { id: 'builtin:fix', enabled: true, shortcut: '' },
110
+ ],
111
+ }
112
+
113
+ /**
114
+ * Bounds for quick-action editing, mirrored by the Host validator. The
115
+ * settings page refuses anything outside these before it ever posts.
116
+ */
117
+ const QUICK_ACTION_LIMITS = {
118
+ maxEntries: 12,
119
+ idLength: 40,
120
+ labelLength: 20,
121
+ questionLength: 500,
122
+ shortcutLength: 40,
123
+ }
124
+
125
+ /**
126
+ * Built-in quick actions. The config list only carries `builtin:<id>`;
127
+ * the localized label and question live HERE so the wire/config layers
128
+ * stay language-neutral. Questions are instructions only — the Host
129
+ * fences the selection separately, so none of them needs to quote text.
130
+ */
131
+ const QUICK_ACTION_BUILTINS = {
132
+ 'builtin:explain': { labelKey: 'qaExplain', questionKey: 'qaExplainQ' },
133
+ 'builtin:translate': { labelKey: 'qaTranslate', questionKey: 'qaTranslateQ' },
134
+ 'builtin:summarize': { labelKey: 'qaSummarize', questionKey: 'qaSummarizeQ' },
135
+ 'builtin:polish': { labelKey: 'qaPolish', questionKey: 'qaPolishQ' },
136
+ 'builtin:fix': { labelKey: 'qaFix', questionKey: 'qaFixQ' },
101
137
  }
102
138
 
103
139
  /** Zone classification: slot-key patterns the harness itself renders. */
@@ -298,6 +334,39 @@ window.__ModuleLoader__.load({
298
334
  familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
299
335
  familyLegacyDiag: '旧版共载',
300
336
  familyPeers: '同页实例',
337
+ retryEdit: '改问',
338
+ editQuestionPlaceholder: '修改问题后重新提问…',
339
+ retryAskSubmit: '重新提问',
340
+ copyCode: '复制代码',
341
+ qaExplain: '解释',
342
+ qaExplainQ: '请用简洁的语言解释这段内容的含义。',
343
+ qaTranslate: '翻译',
344
+ qaTranslateQ: '请把这段内容翻译成中文;如果原文已经是中文,则翻译成英文。只输出译文,不要加解释。',
345
+ qaSummarize: '总结',
346
+ qaSummarizeQ: '请用简短的要点总结这段内容。',
347
+ qaPolish: '润色',
348
+ qaPolishQ: '请润色这段文字:保持原意、修正语病与不通顺之处,只输出润色后的内容。',
349
+ qaFix: '纠错',
350
+ qaFixQ: '请检查这段内容中的事实性、逻辑性与文字错误,逐条列出问题并给出修正建议。',
351
+ setQuickActions: '快捷动作',
352
+ setQuickActionsHint: '选中文字后显示的一键动作。自定义问题中的 {selection} 会替换为选中内容;不写占位符时,原文会随问题自动附上。',
353
+ qaEnabled: '启用',
354
+ qaAdd: '+ 添加动作',
355
+ qaDelete: '删除',
356
+ qaBuiltinBadge: '内置',
357
+ qaLabelPlaceholder: '动作名称(如:英译中)',
358
+ qaQuestionPlaceholder: '问题指令,可用 {selection} 引用选中内容',
359
+ qaShortcutPlaceholder: '如 Alt+T,留空表示无快捷键',
360
+ qaTooMany: '快捷动作最多 {n} 条',
361
+ qaMalformed: '存在格式不正确的动作',
362
+ qaBadId: '动作 id 缺失或超长',
363
+ qaDuplicateId: '存在重复的动作 id',
364
+ qaBadLabel: '动作名称必填,且不超过 {n} 个字符',
365
+ qaBadQuestion: '问题指令必填,且不超过 {n} 个字符',
366
+ qaBadShortcut: '“{label}”的快捷键格式不正确,请用 Alt+Q 这样的写法',
367
+ qaDuplicateShortcut: '“{label}”的快捷键与“{other}”冲突',
368
+ familyLegacyDiag: '旧版共载',
369
+ familyPeers: '同页实例',
301
370
  },
302
371
  en: {
303
372
  triggerLabel: 'Ask about selection',
@@ -438,6 +507,37 @@ window.__ModuleLoader__.load({
438
507
  familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
439
508
  familyLegacyDiag: 'Legacy co-load',
440
509
  familyPeers: 'Copies on page',
510
+ retryEdit: 'Edit & retry',
511
+ editQuestionPlaceholder: 'Edit the question, then ask again…',
512
+ retryAskSubmit: 'Ask again',
513
+ copyCode: 'Copy code',
514
+ qaExplain: 'Explain',
515
+ qaExplainQ: 'Explain what this passage means, concisely.',
516
+ qaTranslate: 'Translate',
517
+ qaTranslateQ: 'Translate this passage into English; if it is already in English, translate it into Chinese. Output the translation only, with no explanation.',
518
+ qaSummarize: 'Summarize',
519
+ qaSummarizeQ: 'Summarize the key points of this passage briefly.',
520
+ qaPolish: 'Polish',
521
+ qaPolishQ: 'Polish this text: keep the original meaning, fix grammar and awkward phrasing, and output only the polished version.',
522
+ qaFix: 'Proofread',
523
+ qaFixQ: 'Check this passage for factual, logical, and writing errors. List each issue with a suggested fix.',
524
+ setQuickActions: 'Quick actions',
525
+ setQuickActionsHint: 'One-click actions shown after selecting text. In a custom question, {selection} is replaced by the selected text; without it, the source text is attached automatically.',
526
+ qaEnabled: 'On',
527
+ qaAdd: '+ Add action',
528
+ qaDelete: 'Delete',
529
+ qaBuiltinBadge: 'built-in',
530
+ qaLabelPlaceholder: 'Action name (e.g. EN→ZH)',
531
+ qaQuestionPlaceholder: 'Question; use {selection} for the selected text',
532
+ qaShortcutPlaceholder: 'e.g. Alt+T; blank for none',
533
+ qaTooMany: 'At most {n} quick actions',
534
+ qaMalformed: 'A malformed action exists',
535
+ qaBadId: 'An action id is missing or too long',
536
+ qaDuplicateId: 'Duplicate action ids exist',
537
+ qaBadLabel: 'Action names are required and at most {n} characters',
538
+ qaBadQuestion: 'Questions are required and at most {n} characters',
539
+ qaBadShortcut: 'The shortcut for "{label}" is invalid; use a form like Alt+Q',
540
+ qaDuplicateShortcut: 'The shortcut for "{label}" conflicts with "{other}"',
441
541
  },
442
542
  }
443
543
 
@@ -1028,6 +1128,134 @@ window.__ModuleLoader__.load({
1028
1128
  && event.metaKey === facts.meta
1029
1129
  }
1030
1130
 
1131
+ /**
1132
+ * Resolve the configured quick actions into renderable, ENABLED entries.
1133
+ * The Host validator keeps a saved list well-formed; this is the
1134
+ * render-time guard for an in-memory/edited draft: unknown `builtin:*`
1135
+ * ids and custom entries missing label/question are skipped.
1136
+ * @param {object|Array} configOrList - the effective config or a raw list.
1137
+ * @returns {Array<{id:string,label:string,question:string,shortcut:string,builtin:boolean}>}
1138
+ */
1139
+ function quickActionsOf(configOrList) {
1140
+ const list = Array.isArray(configOrList) ? configOrList : configOrList?.quickActions
1141
+ if (!Array.isArray(list)) return []
1142
+ const out = []
1143
+ for (const entry of list) {
1144
+ if (entry === null || typeof entry !== 'object') continue
1145
+ if (entry.enabled === false) continue
1146
+ const id = String(entry.id ?? '')
1147
+ const meta = QUICK_ACTION_BUILTINS[id]
1148
+ if (meta !== undefined) {
1149
+ out.push({
1150
+ id,
1151
+ label: t(meta.labelKey),
1152
+ question: t(meta.questionKey),
1153
+ shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
1154
+ builtin: true,
1155
+ })
1156
+ continue
1157
+ }
1158
+ // An unknown builtin id resolves to nothing the client knows.
1159
+ if (id.startsWith('builtin:')) continue
1160
+ if (typeof entry.label === 'string' && entry.label.trim() !== ''
1161
+ && typeof entry.question === 'string' && entry.question.trim() !== '') {
1162
+ out.push({
1163
+ id,
1164
+ label: entry.label,
1165
+ question: entry.question,
1166
+ shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
1167
+ builtin: false,
1168
+ })
1169
+ }
1170
+ }
1171
+ return out
1172
+ }
1173
+
1174
+ /**
1175
+ * The question actually sent for one quick action. A template containing
1176
+ * `{selection}` embeds the (truncated) selection inline; a template
1177
+ * without it is sent as-is, because the Host fences the selection in
1178
+ * every prompt regardless.
1179
+ * @param {{question?: string}} action - a resolved quick action.
1180
+ * @param {string} selection - the raw selected text.
1181
+ * @param {number} maxChars - the selection truncation budget.
1182
+ * @returns {string}
1183
+ */
1184
+ function renderActionQuestion(action, selection, maxChars) {
1185
+ const template = String(action?.question ?? '')
1186
+ if (!template.includes('{selection}')) return template
1187
+ const cut = truncateSelection(String(selection ?? ''), maxChars)
1188
+ return template.split('{selection}').join(cut.text)
1189
+ }
1190
+
1191
+ /** Canonical fingerprint of an accelerator, for duplicate detection. */
1192
+ function shortcutFingerprint(accelerator) {
1193
+ const facts = parseShortcut(accelerator)
1194
+ if (facts === null) return null
1195
+ return `${facts.ctrl ? 1 : 0}|${facts.alt ? 1 : 0}|${facts.shift ? 1 : 0}|${facts.meta ? 1 : 0}|${facts.key}`
1196
+ }
1197
+
1198
+ /**
1199
+ * Validate a settings-page quick-action draft BEFORE posting it, so the
1200
+ * Host's all-or-nothing refusal never comes back as a surprise. Also
1201
+ * checks that no action shortcut collides with another action or with the
1202
+ * main popover shortcut.
1203
+ * @param {unknown} list - the draft `quickActions` array.
1204
+ * @param {string} mainShortcut - the configured popover shortcut.
1205
+ * @returns {{ok: true}|{ok: false, key: string, vars?: object}}
1206
+ */
1207
+ function validateQuickActionsDraft(list, mainShortcut) {
1208
+ if (!Array.isArray(list) || list.length > QUICK_ACTION_LIMITS.maxEntries) {
1209
+ return { ok: false, key: 'qaTooMany' }
1210
+ }
1211
+ const ids = new Set()
1212
+ const taken = new Map()
1213
+ const mainFingerprint = shortcutFingerprint(mainShortcut)
1214
+ if (mainFingerprint !== null) taken.set(mainFingerprint, '')
1215
+ const labelOf = (entry) => {
1216
+ const meta = QUICK_ACTION_BUILTINS[String(entry.id ?? '')]
1217
+ return meta !== undefined ? t(meta.labelKey) : String(entry.label ?? '')
1218
+ }
1219
+ for (const entry of list) {
1220
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
1221
+ return { ok: false, key: 'qaMalformed' }
1222
+ }
1223
+ const id = String(entry.id ?? '')
1224
+ if (id === '' || id.length > QUICK_ACTION_LIMITS.idLength) return { ok: false, key: 'qaBadId' }
1225
+ if (ids.has(id)) return { ok: false, key: 'qaDuplicateId' }
1226
+ ids.add(id)
1227
+ const meta = QUICK_ACTION_BUILTINS[id]
1228
+ const label = labelOf(entry)
1229
+ if (meta === undefined && !id.startsWith('builtin:')) {
1230
+ const customLabel = String(entry.label ?? '')
1231
+ const customQuestion = String(entry.question ?? '')
1232
+ if (customLabel.trim() === '' || customLabel.length > QUICK_ACTION_LIMITS.labelLength) {
1233
+ return { ok: false, key: 'qaBadLabel', vars: { n: QUICK_ACTION_LIMITS.labelLength } }
1234
+ }
1235
+ if (customQuestion.trim() === '' || customQuestion.length > QUICK_ACTION_LIMITS.questionLength) {
1236
+ return { ok: false, key: 'qaBadQuestion', vars: { n: QUICK_ACTION_LIMITS.questionLength } }
1237
+ }
1238
+ }
1239
+ const shortcut = String(entry.shortcut ?? '').trim()
1240
+ if (shortcut !== '') {
1241
+ if (parseShortcut(shortcut) === null) {
1242
+ return { ok: false, key: 'qaBadShortcut', vars: { label } }
1243
+ }
1244
+ const fingerprint = shortcutFingerprint(shortcut)
1245
+ if (taken.has(fingerprint)) {
1246
+ const owner = taken.get(fingerprint)
1247
+ return {
1248
+ ok: false,
1249
+ key: 'qaDuplicateShortcut',
1250
+ vars: { label, other: owner === '' ? t('setShortcut') : owner },
1251
+ }
1252
+ }
1253
+ taken.set(fingerprint, label)
1254
+ }
1255
+ }
1256
+ return { ok: true }
1257
+ }
1258
+
1031
1259
  // ─────────────────────────────────────────────────────────────────────
1032
1260
  // §6 carriers
1033
1261
  // ─────────────────────────────────────────────────────────────────────
@@ -1363,7 +1591,7 @@ window.__ModuleLoader__.load({
1363
1591
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1364
1592
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1365
1593
  Every surface we draw declares it. The property is inert in a browser. */
1366
- .dsa-layer,.dsa-trigger,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1594
+ .dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1367
1595
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1368
1596
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1369
1597
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -1372,6 +1600,26 @@ window.__ModuleLoader__.load({
1372
1600
  font-family:inherit;white-space:nowrap}
1373
1601
  .dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1374
1602
  .dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1603
+ /* The one-click action bar: the trigger button plus compact chips, laid out
1604
+ in one wrapping row under the selection. The container is positioned by the
1605
+ component; chips keep their own pointer-events because .dsa-layer is a
1606
+ click-through layer. */
1607
+ .dsa-quickbar{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:6px;
1608
+ max-width:min(620px, 92vw);flex-wrap:wrap;z-index:60}
1609
+ .dsa-chip{pointer-events:auto;padding:3px 10px;border-radius:999px;
1610
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1611
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1612
+ font:inherit;font-size:12px;line-height:16px;cursor:pointer;white-space:nowrap;
1613
+ box-shadow:0 2px 8px rgba(0,0,0,.14)}
1614
+ .dsa-chip:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1615
+ .dsa-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1616
+ /* Chips rendered INSIDE the popover need no floating shadow. */
1617
+ .dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
1618
+ .dsa-pop-chips .dsa-chip{box-shadow:none}
1619
+ /* Inside the trigger bar the main button and chips are one flex row: the
1620
+ trigger drops its own fixed positioning, chips join the row directly. */
1621
+ .dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
1622
+ .dsa-quickbar-chips{display:contents}
1375
1623
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
1376
1624
  padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1377
1625
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -1456,6 +1704,11 @@ window.__ModuleLoader__.load({
1456
1704
  .dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1457
1705
  padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
1458
1706
  .dsa-table th{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-weight:600}
1707
+ .dsa-pre-wrap{position:relative;margin:0 0 6px}
1708
+ .dsa-pre-wrap .dsa-pre{margin:0}
1709
+ .dsa-code-copy{position:absolute;top:4px;right:4px;padding:1px 7px;line-height:16px;
1710
+ font-size:12px;opacity:.72;background:var(--dsw-alias-bg-overlay, #fff)}
1711
+ .dsa-code-copy:hover,.dsa-code-copy:focus-visible{opacity:1}
1459
1712
  .dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
1460
1713
  background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
1461
1714
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -1474,6 +1727,14 @@ window.__ModuleLoader__.load({
1474
1727
  background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
1475
1728
  .dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
1476
1729
  .dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
1730
+ /* Quick-action editor (settings page): one wrapped row per action. */
1731
+ .dsa-qa{display:flex;flex-direction:column;gap:8px}
1732
+ .dsa-qa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
1733
+ padding:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px}
1734
+ .dsa-qa-row .dsa-input{flex:1;min-width:120px}
1735
+ .dsa-qa-question{flex-basis:100%!important;min-width:0!important}
1736
+ .dsa-qa-head{display:flex;align-items:center;gap:8px;font-size:12px;
1737
+ color:var(--dsw-alias-label-secondary, #555)}
1477
1738
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
1478
1739
  color:var(--dsw-alias-label-secondary, #555)}
1479
1740
  /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
@@ -1559,7 +1820,33 @@ window.__ModuleLoader__.load({
1559
1820
  i += 1
1560
1821
  }
1561
1822
  i += 1
1562
- nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
1823
+ const code = body.join('\n')
1824
+ // A per-block copy button: answers are code-heavy, and dragging a
1825
+ // selection across a scrollable <pre> used to miss lines. The
1826
+ // click closes over the code text (never re-read from the DOM), and
1827
+ // reuses the same clipboard API → selection fallback as card copy.
1828
+ nodes.push(h('div', { key: `f${key++}`, className: 'dsa-pre-wrap' },
1829
+ h('pre', { className: 'dsa-pre' }, h('code', null, code)),
1830
+ h('button', {
1831
+ type: 'button',
1832
+ className: 'dsa-btn dsa-btn-icon dsa-code-copy',
1833
+ title: t('copyCode'),
1834
+ 'aria-label': t('copyCode'),
1835
+ onClick: (event) => {
1836
+ try {
1837
+ const host = event?.currentTarget ?? event?.target
1838
+ const wrap = typeof host?.closest === 'function' ? host.closest('.dsa-pre-wrap') : null
1839
+ const codeNode = wrap !== null && typeof wrap?.querySelector === 'function'
1840
+ ? wrap.querySelector('code')
1841
+ : null
1842
+ void copyText(code, codeNode).then((result) => {
1843
+ toast(result === 'copied'
1844
+ ? t('copied')
1845
+ : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
1846
+ })
1847
+ } catch { /* a clipboard failure must not break the card */ }
1848
+ },
1849
+ }, '⧉')))
1563
1850
  continue
1564
1851
  }
1565
1852
  if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
@@ -1706,15 +1993,15 @@ window.__ModuleLoader__.load({
1706
1993
  // ─────────────────────────────────────────────────────────────────────
1707
1994
 
1708
1995
  /** The in-place trigger button floating at the selection. */
1709
- function TriggerButton({ candidate, onOpen }) {
1996
+ function TriggerButton({ candidate, onOpen, embedded = false }) {
1710
1997
  // The clamp width follows the live label: icon + gap + padding + border
1711
1998
  // (~38px of chrome) plus a safety margin, over the estimated label width.
1712
1999
  const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
1713
- const box = clampBox(candidate.rect, width, 30, 'below')
2000
+ const box = embedded ? null : clampBox(candidate.rect, width, 30, 'below')
1714
2001
  return h('button', {
1715
2002
  type: 'button',
1716
- className: 'dsa-trigger',
1717
- style: { left: `${box.left}px`, top: `${box.top}px` },
2003
+ className: embedded ? 'dsa-trigger dsa-trigger-embedded' : 'dsa-trigger',
2004
+ style: embedded ? undefined : { left: `${box.left}px`, top: `${box.top}px` },
1718
2005
  title: t('triggerHint'),
1719
2006
  'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
1720
2007
  onMouseDown: (event) => { event.preventDefault() },
@@ -1726,14 +2013,64 @@ window.__ModuleLoader__.load({
1726
2013
  }, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
1727
2014
  }
1728
2015
 
2016
+ /**
2017
+ * Compact quick-action chips, shared by the in-place trigger bar and the
2018
+ * popover. The trigger bar lays chips out directly (`display:contents`);
2019
+ * the popover wraps them in its own borderless chip row.
2020
+ */
2021
+ function QuickChips({ actions, onPick, inline = false }) {
2022
+ if (!Array.isArray(actions) || actions.length === 0) return null
2023
+ return h('div', { className: inline ? 'dsa-quickbar-chips' : 'dsa-pop-chips' },
2024
+ actions.map(action => h('button', {
2025
+ key: action.id,
2026
+ type: 'button',
2027
+ className: 'dsa-chip',
2028
+ title: typeof action.shortcut === 'string' && action.shortcut.trim() !== ''
2029
+ ? `${action.question}\n${action.shortcut}`
2030
+ : action.question,
2031
+ 'aria-label': `${action.label}:${action.question}`,
2032
+ onMouseDown: (event) => { event.preventDefault() },
2033
+ onClick: (event) => {
2034
+ event.preventDefault()
2035
+ event.stopPropagation()
2036
+ onPick(action)
2037
+ },
2038
+ }, action.label)))
2039
+ }
2040
+
2041
+ /**
2042
+ * The in-place trigger bar: the main "ask" button followed by every
2043
+ * enabled quick action. A chip click skips the popover entirely — the
2044
+ * question is fixed and the answer goes straight to the side card.
2045
+ */
2046
+ function TriggerBar({ candidate, config, onOpen, onQuick }) {
2047
+ const actions = quickActionsOf(config)
2048
+ const mainWidth = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
2049
+ // Rough row width for viewport clamping: main button + 6px gap + an
2050
+ // estimate per chip (label + ~24px padding/chrome). Wrapped rows cost
2051
+ // height, not width, so the clamp stays usable even when it wraps.
2052
+ const chipsWidth = actions.reduce(
2053
+ (sum, action) => sum + Math.ceil(estimateLabelWidth(action.label) + 24), 0)
2054
+ const rowWidth = Math.min(620, mainWidth + (actions.length > 0 ? 6 + chipsWidth : 0))
2055
+ const box = clampBox(candidate.rect, rowWidth, 34, 'below')
2056
+ return h('div', {
2057
+ className: 'dsa-quickbar',
2058
+ style: { left: `${box.left}px`, top: `${box.top}px` },
2059
+ onMouseDown: event => event.stopPropagation(),
2060
+ },
2061
+ h(TriggerButton, { candidate, onOpen, embedded: true }),
2062
+ h(QuickChips, { actions, onPick: onQuick, inline: true }))
2063
+ }
2064
+
1729
2065
  /** The question popover. */
1730
- function AskPopover({ candidate, config, onClose, onSubmit }) {
2066
+ function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
1731
2067
  const [question, setQuestion] = React.useState('')
1732
2068
  const [carrier, setCarrier] = React.useState(config.defaultCarrier)
1733
2069
  const [busy, setBusy] = React.useState(false)
1734
2070
  const areaRef = React.useRef(null)
1735
2071
  const cut = truncateSelection(candidate.text, config.maxChars)
1736
- const box = clampBox(candidate.rect, 420, 260, 'below')
2072
+ const box = clampBox(candidate.rect, 420, 300, 'below')
2073
+ const quickActions = quickActionsOf(config)
1737
2074
 
1738
2075
  React.useEffect(() => {
1739
2076
  const area = areaRef.current
@@ -1780,6 +2117,10 @@ window.__ModuleLoader__.load({
1780
2117
  codePointLength(candidate.text) > 400
1781
2118
  ? `${Array.from(candidate.text).slice(0, 400).join('')}…`
1782
2119
  : candidate.text),
2120
+ // Same chips as the in-place bar: shortcut-only users reach them via
2121
+ // Alt+Q, and a click here submits directly (the popover closes because
2122
+ // submit/quickAsk both clear `popover`).
2123
+ h(QuickChips, { actions: quickActions, onPick: action => onQuick(action) }),
1783
2124
  h('textarea', {
1784
2125
  ref: areaRef,
1785
2126
  className: 'dsa-textarea',
@@ -1854,8 +2195,8 @@ window.__ModuleLoader__.load({
1854
2195
  reasoning, renderRichText(card.text))
1855
2196
  }
1856
2197
 
1857
- /** Card footer: status, copy, follow-up, retry, close. */
1858
- function CardActions({ card, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
2198
+ /** Card footer: status, copy, follow-up, retry/edit-retry, close. */
2199
+ function CardActions({ card, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain }) {
1859
2200
  const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
1860
2201
  return h('div', { className: 'dsa-row' },
1861
2202
  h('span', { className: 'dsa-muted' },
@@ -1865,9 +2206,15 @@ window.__ModuleLoader__.load({
1865
2206
  card.status === 'streaming'
1866
2207
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
1867
2208
  : null,
1868
- terminal && card.status !== 'done'
2209
+ terminal && card.status !== 'done' && card.carrier === 'side'
1869
2210
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
1870
2211
  : null,
2212
+ // Edit & retry is available for EVERY terminal side card: a typo in
2213
+ // the question used to invalidate the whole card (plain retry only
2214
+ // resends verbatim), and an error card can be corrected the same way.
2215
+ terminal && card.carrier === 'side'
2216
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetryEdit }, t('retryEdit'))
2217
+ : null,
1871
2218
  card.status === 'error' && card.carrier === 'side'
1872
2219
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
1873
2220
  : null,
@@ -1893,9 +2240,14 @@ window.__ModuleLoader__.load({
1893
2240
  * (the layer is the only surface that can cover the conversation). A host
1894
2241
  * tab that embeds the same card passes no callback and gets no buttons.
1895
2242
  */
1896
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
2243
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode }) {
1897
2244
  const [followOpen, setFollowOpen] = React.useState(false)
1898
2245
  const [draft, setDraft] = React.useState('')
2246
+ // Edit-&-retry editor: prefilled with the card's CURRENT question and
2247
+ // kept separate from the follow-up draft (follow-up appends a turn;
2248
+ // edit-retry regenerates THIS turn, keeping the card's earlier turns).
2249
+ const [editOpen, setEditOpen] = React.useState(false)
2250
+ const [editDraft, setEditDraft] = React.useState('')
1899
2251
  const answerRef = React.useRef(null)
1900
2252
  const config = store.state.config
1901
2253
  // Icon-only, so the header row keeps one line: the full labels
@@ -1947,12 +2299,60 @@ window.__ModuleLoader__.load({
1947
2299
  },
1948
2300
  }))
1949
2301
  : null,
2302
+ editOpen
2303
+ ? h('div', { className: 'dsa-field' },
2304
+ h('textarea', {
2305
+ className: 'dsa-textarea',
2306
+ value: editDraft,
2307
+ placeholder: t('editQuestionPlaceholder'),
2308
+ autoFocus: true,
2309
+ onChange: event => setEditDraft(event.target.value),
2310
+ onKeyDown: (event) => {
2311
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
2312
+ event.preventDefault()
2313
+ const text = editDraft.trim()
2314
+ if (text === '') return
2315
+ setEditDraft('')
2316
+ setEditOpen(false)
2317
+ onRetryEdit(text)
2318
+ }
2319
+ if (event.key === 'Escape') {
2320
+ event.stopPropagation()
2321
+ setEditOpen(false)
2322
+ }
2323
+ },
2324
+ }),
2325
+ h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
2326
+ h('button', {
2327
+ type: 'button',
2328
+ className: 'dsa-btn',
2329
+ onClick: () => setEditOpen(false),
2330
+ }, t('askCancel')),
2331
+ h('button', {
2332
+ type: 'button',
2333
+ className: 'dsa-btn dsa-btn-primary',
2334
+ disabled: editDraft.trim() === '',
2335
+ onClick: () => {
2336
+ const text = editDraft.trim()
2337
+ if (text === '') return
2338
+ setEditDraft('')
2339
+ setEditOpen(false)
2340
+ onRetryEdit(text)
2341
+ },
2342
+ }, t('retryAskSubmit'))))
2343
+ : null,
1950
2344
  h(CardActions, {
1951
2345
  card,
1952
2346
  onCopy: () => onCopy(answerRef.current),
1953
2347
  onRetry,
1954
2348
  onCancel,
1955
2349
  onSendToMain,
2350
+ onRetryEdit: () => {
2351
+ // Open with the live question, so reopening after an earlier edit
2352
+ // re-edits the question actually asked, not the card's first one.
2353
+ setEditDraft(card.question)
2354
+ setEditOpen(open => !open)
2355
+ },
1956
2356
  onFollowUp: () => setFollowOpen(open => !open),
1957
2357
  onClose,
1958
2358
  }),
@@ -2084,6 +2484,7 @@ window.__ModuleLoader__.load({
2084
2484
  onCopy: element => actions.copy(card, element),
2085
2485
  onFollowUp: text => actions.followUp(card, text),
2086
2486
  onRetry: () => actions.retry(card),
2487
+ onRetryEdit: text => actions.retryEdit(card, text),
2087
2488
  onCancel: () => actions.cancel(card),
2088
2489
  onSendToMain: () => actions.sendToMain(card),
2089
2490
  onClose: () => actions.close(card),
@@ -2109,7 +2510,12 @@ window.__ModuleLoader__.load({
2109
2510
  },
2110
2511
  h(Styles, null),
2111
2512
  candidate !== null && popover === null && config.trigger !== 'shortcut'
2112
- ? h(TriggerButton, { candidate, onOpen: () => actions.openPopover(candidate) })
2513
+ ? h(TriggerBar, {
2514
+ candidate,
2515
+ config,
2516
+ onOpen: () => actions.openPopover(candidate),
2517
+ onQuick: action => actions.quickAsk(candidate, action),
2518
+ })
2113
2519
  : null,
2114
2520
  popover !== null
2115
2521
  ? h(AskPopover, {
@@ -2117,6 +2523,7 @@ window.__ModuleLoader__.load({
2117
2523
  config,
2118
2524
  onClose: () => actions.closePopover(),
2119
2525
  onSubmit: payload => actions.submit(popover.candidate, payload),
2526
+ onQuick: action => actions.quickAsk(popover.candidate, action),
2120
2527
  })
2121
2528
  : null,
2122
2529
  h(FloatingLayer, { cards: state.cards, actions }),
@@ -2172,11 +2579,95 @@ window.__ModuleLoader__.load({
2172
2579
  }, options.map(name => h('option', { key: name, value: name }, name)))
2173
2580
  }
2174
2581
 
2582
+ // ── quick-action list editor ───────────────────────────────────────
2583
+ // The draft always carries the FULL list (the user layer replaces
2584
+ // defaults wholesale): built-in rows are editable only in
2585
+ // enabled/shortcut, custom rows also in label/question.
2586
+ const qaList = () => (Array.isArray(draft.quickActions) ? draft.quickActions : [])
2587
+ const patchAction = (index, patch) => {
2588
+ const quickActions = qaList().map((entry, i) => (i === index ? { ...entry, ...patch } : entry))
2589
+ setDraft({ ...draft, quickActions })
2590
+ }
2591
+ const addAction = () => {
2592
+ const list = qaList()
2593
+ // Host cap is enforced on save; do not even append beyond it.
2594
+ if (list.length >= QUICK_ACTION_LIMITS.maxEntries) return
2595
+ const id = `custom:${Date.now().toString(36)}-${list.length + 1}`
2596
+ setDraft({ ...draft, quickActions: [...list, { id, label: '', question: '', enabled: true, shortcut: '' }] })
2597
+ }
2598
+ const removeAction = (index) => {
2599
+ setDraft({ ...draft, quickActions: qaList().filter((_, i) => i !== index) })
2600
+ }
2601
+ const quickActionsEditor = h('div', { className: 'dsa-field' },
2602
+ h('label', null, t('setQuickActions')),
2603
+ h('div', { className: 'dsa-qa' },
2604
+ qaList().map((entry, index) => {
2605
+ const meta = QUICK_ACTION_BUILTINS[String(entry.id)]
2606
+ const isBuiltin = meta !== undefined
2607
+ return h('div', { key: `${entry.id}-${index}`, className: 'dsa-qa-row' },
2608
+ h('label', { className: 'dsa-check', title: t('qaEnabled') },
2609
+ h('input', {
2610
+ type: 'checkbox',
2611
+ checked: entry.enabled !== false,
2612
+ onChange: event => patchAction(index, { enabled: event.target.checked }),
2613
+ }),
2614
+ t('qaEnabled')),
2615
+ isBuiltin ? h('span', { className: 'dsa-badge' }, t('qaBuiltinBadge')) : null,
2616
+ isBuiltin
2617
+ ? h('span', { className: 'dsa-title' }, t(meta.labelKey))
2618
+ : h('input', {
2619
+ className: 'dsa-input',
2620
+ value: String(entry.label ?? ''),
2621
+ maxLength: QUICK_ACTION_LIMITS.labelLength,
2622
+ placeholder: t('qaLabelPlaceholder'),
2623
+ onChange: event => patchAction(index, { label: event.target.value }),
2624
+ }),
2625
+ h('input', {
2626
+ className: 'dsa-input',
2627
+ style: { maxWidth: '150px' },
2628
+ value: String(entry.shortcut ?? ''),
2629
+ maxLength: QUICK_ACTION_LIMITS.shortcutLength,
2630
+ placeholder: t('qaShortcutPlaceholder'),
2631
+ onChange: event => patchAction(index, { shortcut: event.target.value }),
2632
+ }),
2633
+ isBuiltin
2634
+ ? null
2635
+ : h('button', {
2636
+ type: 'button',
2637
+ className: 'dsa-btn',
2638
+ onClick: () => removeAction(index),
2639
+ }, t('qaDelete')),
2640
+ isBuiltin
2641
+ ? h('div', { className: 'dsa-muted dsa-qa-question' }, t(meta.questionKey))
2642
+ : h('input', {
2643
+ className: 'dsa-input dsa-qa-question',
2644
+ value: String(entry.question ?? ''),
2645
+ maxLength: QUICK_ACTION_LIMITS.questionLength,
2646
+ placeholder: t('qaQuestionPlaceholder'),
2647
+ onChange: event => patchAction(index, { question: event.target.value }),
2648
+ }))
2649
+ }),
2650
+ h('div', null, h('button', {
2651
+ type: 'button',
2652
+ className: 'dsa-btn',
2653
+ disabled: qaList().length >= QUICK_ACTION_LIMITS.maxEntries,
2654
+ onClick: addAction,
2655
+ }, t('qaAdd')))),
2656
+ h('span', { className: 'dsa-muted' }, t('setQuickActionsHint')))
2657
+
2175
2658
  const save = async () => {
2176
2659
  if (parseShortcut(draft.shortcut) === null) {
2177
2660
  setStatus(`error:${t('unsupportedTrigger')}`)
2178
2661
  return
2179
2662
  }
2663
+ // Pre-flight the quick-action list: the Host would all-or-nothing
2664
+ // refuse it anyway, but a local check names the offending row instead
2665
+ // of surfacing a wire error after the save.
2666
+ const qaResult = validateQuickActionsDraft(draft.quickActions, draft.shortcut)
2667
+ if (qaResult.ok === false) {
2668
+ setStatus(`error:${t(qaResult.key, qaResult.vars)}`)
2669
+ return
2670
+ }
2180
2671
  setStatus('saving')
2181
2672
  try {
2182
2673
  await actions.saveConfig(draft)
@@ -2242,6 +2733,7 @@ window.__ModuleLoader__.load({
2242
2733
  ])),
2243
2734
  field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
2244
2735
  field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
2736
+ quickActionsEditor,
2245
2737
  h('label', { className: 'dsa-check' },
2246
2738
  h('input', {
2247
2739
  type: 'checkbox',
@@ -2582,6 +3074,18 @@ window.__ModuleLoader__.load({
2582
3074
  }
2583
3075
  void runSideCard(card.id)
2584
3076
  },
3077
+ /**
3078
+ * One quick-action chip: resolve the template ({selection} is the
3079
+ * only placeholder), then take the SAME path as a popover submit
3080
+ * fixed to the side card — surface picking, render proof and the
3081
+ * stream loop all live in submit(), so this stays a thin adapter.
3082
+ */
3083
+ quickAsk(candidate, action) {
3084
+ const config = store.state.config
3085
+ const question = renderActionQuestion(action, candidate.text, config.maxChars)
3086
+ if (question.trim() === '') return
3087
+ void actions.submit(candidate, { question, carrier: 'side' })
3088
+ },
2585
3089
  copy(card, element) {
2586
3090
  void copyText(card.text, element).then((result) => {
2587
3091
  toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
@@ -2622,6 +3126,25 @@ window.__ModuleLoader__.load({
2622
3126
  patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
2623
3127
  if (card.carrier === 'side') void runSideCard(card.id)
2624
3128
  },
3129
+ /**
3130
+ * Regenerate the CURRENT turn with an edited question. Unlike
3131
+ * followUp, this does NOT push the old Q/A into `history`: the
3132
+ * earlier turns on the card stay, the question text is replaced,
3133
+ * and the answer streams again from scratch.
3134
+ */
3135
+ retryEdit(card, question) {
3136
+ const trimmed = String(question ?? '').trim()
3137
+ if (trimmed === '' || card.carrier !== 'side') return
3138
+ patchCard(card.id, {
3139
+ question: trimmed,
3140
+ status: 'pending',
3141
+ text: '',
3142
+ reasoning: '',
3143
+ error: null,
3144
+ streaming: null,
3145
+ })
3146
+ void runSideCard(card.id)
3147
+ },
2625
3148
  cancel(card) {
2626
3149
  const controller = controllers.get(card.id)
2627
3150
  if (controller !== undefined) {
@@ -2863,6 +3386,14 @@ window.__ModuleLoader__.load({
2863
3386
  // §4.5 only the leader answers the shortcut; otherwise two copies
2864
3387
  // would each preventDefault and each open their own popover.
2865
3388
  if (store.state.familyLeader === false) return
3389
+ // Never steal keys while the user is typing in our own popover or
3390
+ // card editors (Enter/Escape there are handled by the components).
3391
+ const target = elementOf(event.target)
3392
+ if (target !== null && target.closest('[data-dsa-root]') !== null
3393
+ && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'
3394
+ || target.closest('[contenteditable=""],[contenteditable="true"]') !== null)) {
3395
+ return
3396
+ }
2866
3397
  const config = store.state.config
2867
3398
  const facts = parseShortcut(config.shortcut)
2868
3399
  if (matchesShortcut(event, facts)) {
@@ -2873,6 +3404,18 @@ window.__ModuleLoader__.load({
2873
3404
  return
2874
3405
  }
2875
3406
  }
3407
+ // Per-action shortcuts: the main shortcut wins on a collision, and
3408
+ // the candidate falls back to the last raised trigger, exactly like
3409
+ // the main shortcut. A chip then submits straight to a side card.
3410
+ for (const action of quickActionsOf(config)) {
3411
+ if (action.shortcut === '' || !matchesShortcut(event, parseShortcut(action.shortcut))) continue
3412
+ const candidate = readSelection() ?? store.state.trigger
3413
+ if (candidate !== null) {
3414
+ event.preventDefault()
3415
+ actions.quickAsk(candidate, action)
3416
+ return
3417
+ }
3418
+ }
2876
3419
  if (event.key === 'Escape') {
2877
3420
  if (store.state.popover !== null) store.set({ popover: null })
2878
3421
  }
@@ -3288,6 +3831,7 @@ window.__ModuleLoader__.load({
3288
3831
  onCopy: element => actions.copy(card, element),
3289
3832
  onFollowUp: text => actions.followUp(card, text),
3290
3833
  onRetry: () => actions.retry(card),
3834
+ onRetryEdit: text => actions.retryEdit(card, text),
3291
3835
  onCancel: () => actions.cancel(card),
3292
3836
  onSendToMain: () => { void actions.sendToMain(card) },
3293
3837
  onClose: () => actions.close(card),
@@ -3423,6 +3967,12 @@ window.__ModuleLoader__.load({
3423
3967
  truncateSelection,
3424
3968
  parseShortcut,
3425
3969
  matchesShortcut,
3970
+ quickActionsOf,
3971
+ renderActionQuestion,
3972
+ validateQuickActionsDraft,
3973
+ shortcutFingerprint,
3974
+ QUICK_ACTION_BUILTINS,
3975
+ QUICK_ACTION_LIMITS,
3426
3976
  classifyZone,
3427
3977
  slotPathOf,
3428
3978
  shouldOffer,
package/index.js CHANGED
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
42
42
  export const inject = ['webServer']
43
43
 
44
44
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.6.0'
45
+ export const PLUGIN_VERSION = '1.7.0'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -83,6 +83,32 @@ export const DEFAULT_CONFIG = {
83
83
  floatMode: 'capsule',
84
84
  /** Hard cap, in CSS px, for the floating layer's width. */
85
85
  floatMaxWidth: 340,
86
+ /**
87
+ * One-click quick actions rendered beside the selection trigger.
88
+ *
89
+ * Each entry is `{id, enabled, shortcut}`: the five `builtin:*` ids resolve
90
+ * their localized label/question on the Client (the Host stays language
91
+ * neutral); custom entries additionally carry `label` and `question`, and
92
+ * the question may embed a `{selection}` placeholder. The user layer
93
+ * REPLACES this list wholesale (the settings page edits the full list),
94
+ * which is why it can stay a plain JSON array with no merge semantics.
95
+ */
96
+ quickActions: [
97
+ { id: 'builtin:explain', enabled: true, shortcut: '' },
98
+ { id: 'builtin:translate', enabled: true, shortcut: '' },
99
+ { id: 'builtin:summarize', enabled: true, shortcut: '' },
100
+ { id: 'builtin:polish', enabled: true, shortcut: '' },
101
+ { id: 'builtin:fix', enabled: true, shortcut: '' },
102
+ ],
103
+ }
104
+
105
+ /** Bounds for the `quickActions` user layer (defense in depth). */
106
+ const QUICK_ACTION_LIMITS = {
107
+ maxEntries: 12,
108
+ idLength: 40,
109
+ labelLength: 20,
110
+ questionLength: 500,
111
+ shortcutLength: 40,
86
112
  }
87
113
 
88
114
  /**
@@ -164,6 +190,85 @@ function clampNumber(value, min, max) {
164
190
  return Math.min(max, Math.max(min, Math.round(value)))
165
191
  }
166
192
 
193
+ /**
194
+ * Coerce one raw `quickActions` layer into a known-good list.
195
+ *
196
+ * The list is validated ALL-OR-NOTHING: a single malformed entry rejects the
197
+ * whole key (and `/config` answers 400), so the settings page can never
198
+ * silently save a half-edited list. Bounds mirror the Client's own
199
+ * `QUICK_ACTION_LIMITS`; structural validity only — the Host neither knows
200
+ * the builtin catalog nor interprets `question` text.
201
+ * @param {unknown} raw - the candidate array.
202
+ * @param {string[]} problems - collector for human-readable notes.
203
+ * @returns {Array<object>|undefined} the sanitized list, or undefined when
204
+ * the key is absent/unusable (undefined = "do not override", a present but
205
+ * bad array is reported and dropped).
206
+ */
207
+ export function sanitizeQuickActions(raw, problems = []) {
208
+ if (raw === undefined) return undefined
209
+ if (!Array.isArray(raw)) {
210
+ problems.push('quickActions: 必须是数组,已忽略')
211
+ return undefined
212
+ }
213
+ if (raw.length > QUICK_ACTION_LIMITS.maxEntries) {
214
+ problems.push(`quickActions: 最多 ${QUICK_ACTION_LIMITS.maxEntries} 条,已忽略`)
215
+ return undefined
216
+ }
217
+ const ids = new Set()
218
+ const list = []
219
+ for (const [index, entry] of raw.entries()) {
220
+ const where = `quickActions[${index + 1}]`
221
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
222
+ problems.push(`${where}: 必须是对象,已忽略整份快捷动作配置`)
223
+ return undefined
224
+ }
225
+ const id = entry.id
226
+ if (typeof id !== 'string' || id.trim() === '' || id.length > QUICK_ACTION_LIMITS.idLength) {
227
+ problems.push(`${where}: id 缺失或超长(≤${QUICK_ACTION_LIMITS.idLength} 字符),已忽略整份快捷动作配置`)
228
+ return undefined
229
+ }
230
+ if (ids.has(id)) {
231
+ problems.push(`${where}: id "${id}" 重复,已忽略整份快捷动作配置`)
232
+ return undefined
233
+ }
234
+ ids.add(id)
235
+ const shortcut = entry.shortcut
236
+ if (shortcut !== undefined && (typeof shortcut !== 'string' || shortcut.length > QUICK_ACTION_LIMITS.shortcutLength)) {
237
+ problems.push(`${where}: shortcut 必须是 ≤${QUICK_ACTION_LIMITS.shortcutLength} 字符的字符串,已忽略整份快捷动作配置`)
238
+ return undefined
239
+ }
240
+ const safe = {
241
+ id,
242
+ enabled: entry.enabled === undefined ? true : Boolean(entry.enabled),
243
+ shortcut: typeof shortcut === 'string' ? shortcut : '',
244
+ }
245
+ // `builtin:*` ids resolve on the Client; anything else is a custom action
246
+ // and MUST carry its own label/question. The prefix is reserved so a user
247
+ // layer can never shadow a builtin with a malicious look-alike entry.
248
+ const isBuiltin = id.startsWith('builtin:')
249
+ if (isBuiltin && id === 'builtin:') {
250
+ problems.push(`${where}: 内置动作 id 不能为空,已忽略整份快捷动作配置`)
251
+ return undefined
252
+ }
253
+ if (!isBuiltin) {
254
+ const label = entry.label
255
+ const question = entry.question
256
+ if (typeof label !== 'string' || label.trim() === '' || label.length > QUICK_ACTION_LIMITS.labelLength) {
257
+ problems.push(`${where}: 自定义动作 label 缺失或超长(1–${QUICK_ACTION_LIMITS.labelLength} 字符),已忽略整份快捷动作配置`)
258
+ return undefined
259
+ }
260
+ if (typeof question !== 'string' || question.trim() === '' || question.length > QUICK_ACTION_LIMITS.questionLength) {
261
+ problems.push(`${where}: 自定义动作 question 缺失或超长(1–${QUICK_ACTION_LIMITS.questionLength} 字符),已忽略整份快捷动作配置`)
262
+ return undefined
263
+ }
264
+ safe.label = label
265
+ safe.question = question
266
+ }
267
+ list.push(safe)
268
+ }
269
+ return list
270
+ }
271
+
167
272
  /**
168
273
  * Coerce one raw config layer into a known-good shape.
169
274
  * Unknown keys are dropped; out-of-range values fall back, and every
@@ -200,6 +305,10 @@ export function sanitizeLayer(raw, problems = []) {
200
305
  if (typeof raw.showInUnclassified === 'boolean') out.showInUnclassified = raw.showInUnclassified
201
306
  else problems.push('showInUnclassified: 必须是布尔值,已忽略')
202
307
  }
308
+ if (raw.quickActions !== undefined) {
309
+ const list = sanitizeQuickActions(raw.quickActions, problems)
310
+ if (list !== undefined) out.quickActions = list
311
+ }
203
312
  return out
204
313
  }
205
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",