dsh-sidecard-ask 1.5.1 → 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/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.5.1'
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. */
@@ -294,6 +330,43 @@ window.__ModuleLoader__.load({
294
330
  none: '无',
295
331
  unsupportedTrigger: '无法识别该快捷键,请用形如 Alt+Q / Ctrl+Shift+K 的写法',
296
332
  surfaceUnavailable: '所选侧边承载面不可用',
333
+ familySuppressed: '检测到同系列更新版本({version})已在本页运行,本实例已自动停用划词触发与快捷键,界面由更新版本接管。建议在插件页卸载本旧版本。',
334
+ familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
335
+ familyLegacyDiag: '旧版共载',
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: '同页实例',
297
370
  },
298
371
  en: {
299
372
  triggerLabel: 'Ask about selection',
@@ -430,6 +503,41 @@ window.__ModuleLoader__.load({
430
503
  none: 'none',
431
504
  unsupportedTrigger: 'Unrecognized shortcut — use a form like Alt+Q / Ctrl+Shift+K',
432
505
  surfaceUnavailable: 'The chosen side surface is unavailable',
506
+ familySuppressed: 'A newer family build ({version}) is already running on this page; this copy has disabled its trigger and shortcut so only one UI answers. Uninstall this older package.',
507
+ familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
508
+ familyLegacyDiag: 'Legacy co-load',
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}"',
433
541
  },
434
542
  }
435
543
 
@@ -659,6 +767,12 @@ window.__ModuleLoader__.load({
659
767
  zoneAnchors: null,
660
768
  /** Which ladder rung each registration actually landed on. */
661
769
  slots: {},
770
+ /** §4.5 family election: this copy owns the interaction surface. */
771
+ familyLeader: true,
772
+ /** Other cooperating copies currently live in this page. */
773
+ familyPeers: [],
774
+ /** A non-cooperating legacy build (≤1.0.x) is present and suppressed. */
775
+ familyLegacy: false,
662
776
  revision: 0,
663
777
  },
664
778
  listeners: new Set(),
@@ -686,6 +800,94 @@ window.__ModuleLoader__.load({
686
800
  return store.state
687
801
  }
688
802
 
803
+ // ─────────────────────────────────────────────────────────────────────
804
+ // §4.5 family singleton
805
+ //
806
+ // The plugin shipped under two npm names (`dsh-selection-followup` ≤1.0.1
807
+ // and `dsh-sidecard-ask` ≥1.1.0), and a profile can accidentally enable
808
+ // BOTH bundles — or the same bundle twice. Two live copies used to mean
809
+ // two trigger buttons stacked on the selection and two Alt+Q popovers.
810
+ //
811
+ // Cooperating copies (any build with this section) share one registry on
812
+ // `window` and elect a single leader by version (ties → first loaded);
813
+ // non-leaders keep their settings page but mount NO interaction surface.
814
+ //
815
+ // The old 1.0.x build cannot cooperate: it is detected structurally (its
816
+ // overlay carries the same `dsa-layer` class without an owner marker), the
817
+ // leader hides its UI with one stylesheet rule, and the user is told to
818
+ // uninstall the old package.
819
+ // ─────────────────────────────────────────────────────────────────────
820
+
821
+ const FAMILY_WINDOW_KEY = '__dshSidecardFamily'
822
+ const INSTANCE_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
823
+ const OWNER_TOKEN = `${PLUGIN_ID}@${CLIENT_VERSION}#${INSTANCE_ID}`
824
+
825
+ /** Compare dotted numeric versions; non-numeric parts sort first. */
826
+ function compareVersions(a, b) {
827
+ const parts = v => String(v).split(/[.+-]/).map(part => (/^\d+$/.test(part) ? Number(part) : -1))
828
+ const pa = parts(a)
829
+ const pb = parts(b)
830
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
831
+ const x = pa[i] ?? -1
832
+ const y = pb[i] ?? -1
833
+ if (x !== y) return x < y ? -1 : 1
834
+ }
835
+ return 0
836
+ }
837
+
838
+ /**
839
+ * Elect the leader: highest version wins; a tie goes to the member that
840
+ * joined first (Map insertion order). Pure so the contract test can pin it.
841
+ * @returns {string} the winning member id.
842
+ */
843
+ function electFamilyLeader(members, selfId) {
844
+ let winner = null
845
+ for (const member of members) {
846
+ // Strictly greater version replaces; an equal version keeps the member
847
+ // that joined earlier (Map insertion order = load order).
848
+ if (winner === null || compareVersions(member.version, winner.version) > 0) {
849
+ winner = member
850
+ }
851
+ }
852
+ return winner?.id ?? selfId
853
+ }
854
+
855
+ /** Fetch-or-create the cross-copy registry (survives HMR on `window`). */
856
+ function familyTable(win) {
857
+ const existing = win[FAMILY_WINDOW_KEY]
858
+ if (existing !== undefined && existing !== null && typeof existing.subscribe === 'function') {
859
+ return existing
860
+ }
861
+ const members = new Map()
862
+ const listeners = new Set()
863
+ const emit = () => { for (const fn of [...listeners]) { try { fn() } catch { /* peer listener */ } } }
864
+ const table = {
865
+ join(member) { members.set(member.id, member); emit() },
866
+ leave(id) { members.delete(id); emit() },
867
+ list: () => [...members.values()],
868
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
869
+ }
870
+ try {
871
+ Object.defineProperty(win, FAMILY_WINDOW_KEY, { value: table, configurable: true, writable: true })
872
+ } catch {
873
+ win[FAMILY_WINDOW_KEY] = table
874
+ }
875
+ return table
876
+ }
877
+
878
+ /**
879
+ * Find legacy (≤1.0.x, non-cooperating) family UI: a `dsa-layer` carrying
880
+ * a trigger button but no owner marker. Every layer this build renders
881
+ * stamps `data-dsa-owner`, so an unmarked layer can only be the old build.
882
+ */
883
+ function findLegacyLayers(root) {
884
+ if (typeof root.querySelectorAll !== 'function') return []
885
+ try {
886
+ return [...root.querySelectorAll('.dsa-layer:not([data-dsa-owner])')]
887
+ .filter(node => node.querySelector?.('.dsa-trigger, .dsa-pop') !== null)
888
+ } catch { return [] }
889
+ }
890
+
689
891
  /**
690
892
  * In-session override of the configured floating-layer shape. Deliberately
691
893
  * NOT persisted: the settings page owns the durable default, and a reload
@@ -926,6 +1128,134 @@ window.__ModuleLoader__.load({
926
1128
  && event.metaKey === facts.meta
927
1129
  }
928
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
+
929
1259
  // ─────────────────────────────────────────────────────────────────────
930
1260
  // §6 carriers
931
1261
  // ─────────────────────────────────────────────────────────────────────
@@ -1210,6 +1540,12 @@ window.__ModuleLoader__.load({
1210
1540
  ...(surfaces.better.reason === null ? {} : { reason: String(surfaces.better.reason) }),
1211
1541
  features: surfaces.better.features,
1212
1542
  },
1543
+ // §4.5 family singleton verdict (host-side diagnostics only).
1544
+ family: {
1545
+ leader: state.familyLeader !== false,
1546
+ peers: Array.isArray(state.familyPeers) ? state.familyPeers : [],
1547
+ legacy: state.familyLegacy === true,
1548
+ },
1213
1549
  }
1214
1550
  }
1215
1551
 
@@ -1255,7 +1591,7 @@ window.__ModuleLoader__.load({
1255
1591
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1256
1592
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1257
1593
  Every surface we draw declares it. The property is inert in a browser. */
1258
- .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}
1259
1595
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1260
1596
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1261
1597
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -1264,6 +1600,26 @@ window.__ModuleLoader__.load({
1264
1600
  font-family:inherit;white-space:nowrap}
1265
1601
  .dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1266
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}
1267
1623
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
1268
1624
  padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1269
1625
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -1348,6 +1704,11 @@ window.__ModuleLoader__.load({
1348
1704
  .dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1349
1705
  padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
1350
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}
1351
1712
  .dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
1352
1713
  background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
1353
1714
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -1366,8 +1727,25 @@ window.__ModuleLoader__.load({
1366
1727
  background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
1367
1728
  .dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
1368
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)}
1369
1738
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
1370
1739
  color:var(--dsw-alias-label-secondary, #555)}
1740
+ /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
1741
+ dsh-selection-followup ≤1.0.x build. When this build is the elected
1742
+ leader it hides the old build's whole interaction surface so exactly
1743
+ one trigger button / popover answers the selection. Our own layer always
1744
+ carries data-dsa-owner, so this never matches current-generation UI. */
1745
+ .dsa-layer:not([data-dsa-owner]){display:none!important}
1746
+ .dsa-family-notice{padding:8px 10px;border-radius:8px;font-size:12px;
1747
+ border:1px solid var(--dsw-alias-state-warn-primary, #b8860b);
1748
+ color:var(--dsw-alias-state-warn-primary, #b8860b)}
1371
1749
  `
1372
1750
 
1373
1751
  /** Render the stylesheet as a React element (removed with its component). */
@@ -1442,7 +1820,33 @@ window.__ModuleLoader__.load({
1442
1820
  i += 1
1443
1821
  }
1444
1822
  i += 1
1445
- 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
+ }, '⧉')))
1446
1850
  continue
1447
1851
  }
1448
1852
  if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
@@ -1589,15 +1993,15 @@ window.__ModuleLoader__.load({
1589
1993
  // ─────────────────────────────────────────────────────────────────────
1590
1994
 
1591
1995
  /** The in-place trigger button floating at the selection. */
1592
- function TriggerButton({ candidate, onOpen }) {
1996
+ function TriggerButton({ candidate, onOpen, embedded = false }) {
1593
1997
  // The clamp width follows the live label: icon + gap + padding + border
1594
1998
  // (~38px of chrome) plus a safety margin, over the estimated label width.
1595
1999
  const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
1596
- const box = clampBox(candidate.rect, width, 30, 'below')
2000
+ const box = embedded ? null : clampBox(candidate.rect, width, 30, 'below')
1597
2001
  return h('button', {
1598
2002
  type: 'button',
1599
- className: 'dsa-trigger',
1600
- 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` },
1601
2005
  title: t('triggerHint'),
1602
2006
  'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
1603
2007
  onMouseDown: (event) => { event.preventDefault() },
@@ -1609,14 +2013,64 @@ window.__ModuleLoader__.load({
1609
2013
  }, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
1610
2014
  }
1611
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
+
1612
2065
  /** The question popover. */
1613
- function AskPopover({ candidate, config, onClose, onSubmit }) {
2066
+ function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
1614
2067
  const [question, setQuestion] = React.useState('')
1615
2068
  const [carrier, setCarrier] = React.useState(config.defaultCarrier)
1616
2069
  const [busy, setBusy] = React.useState(false)
1617
2070
  const areaRef = React.useRef(null)
1618
2071
  const cut = truncateSelection(candidate.text, config.maxChars)
1619
- const box = clampBox(candidate.rect, 420, 260, 'below')
2072
+ const box = clampBox(candidate.rect, 420, 300, 'below')
2073
+ const quickActions = quickActionsOf(config)
1620
2074
 
1621
2075
  React.useEffect(() => {
1622
2076
  const area = areaRef.current
@@ -1663,6 +2117,10 @@ window.__ModuleLoader__.load({
1663
2117
  codePointLength(candidate.text) > 400
1664
2118
  ? `${Array.from(candidate.text).slice(0, 400).join('')}…`
1665
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) }),
1666
2124
  h('textarea', {
1667
2125
  ref: areaRef,
1668
2126
  className: 'dsa-textarea',
@@ -1737,8 +2195,8 @@ window.__ModuleLoader__.load({
1737
2195
  reasoning, renderRichText(card.text))
1738
2196
  }
1739
2197
 
1740
- /** Card footer: status, copy, follow-up, retry, close. */
1741
- 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 }) {
1742
2200
  const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
1743
2201
  return h('div', { className: 'dsa-row' },
1744
2202
  h('span', { className: 'dsa-muted' },
@@ -1748,9 +2206,15 @@ window.__ModuleLoader__.load({
1748
2206
  card.status === 'streaming'
1749
2207
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
1750
2208
  : null,
1751
- terminal && card.status !== 'done'
2209
+ terminal && card.status !== 'done' && card.carrier === 'side'
1752
2210
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
1753
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,
1754
2218
  card.status === 'error' && card.carrier === 'side'
1755
2219
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
1756
2220
  : null,
@@ -1776,9 +2240,14 @@ window.__ModuleLoader__.load({
1776
2240
  * (the layer is the only surface that can cover the conversation). A host
1777
2241
  * tab that embeds the same card passes no callback and gets no buttons.
1778
2242
  */
1779
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
2243
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode }) {
1780
2244
  const [followOpen, setFollowOpen] = React.useState(false)
1781
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('')
1782
2251
  const answerRef = React.useRef(null)
1783
2252
  const config = store.state.config
1784
2253
  // Icon-only, so the header row keeps one line: the full labels
@@ -1830,12 +2299,60 @@ window.__ModuleLoader__.load({
1830
2299
  },
1831
2300
  }))
1832
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,
1833
2344
  h(CardActions, {
1834
2345
  card,
1835
2346
  onCopy: () => onCopy(answerRef.current),
1836
2347
  onRetry,
1837
2348
  onCancel,
1838
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
+ },
1839
2356
  onFollowUp: () => setFollowOpen(open => !open),
1840
2357
  onClose,
1841
2358
  }),
@@ -1967,6 +2484,7 @@ window.__ModuleLoader__.load({
1967
2484
  onCopy: element => actions.copy(card, element),
1968
2485
  onFollowUp: text => actions.followUp(card, text),
1969
2486
  onRetry: () => actions.retry(card),
2487
+ onRetryEdit: text => actions.retryEdit(card, text),
1970
2488
  onCancel: () => actions.cancel(card),
1971
2489
  onSendToMain: () => actions.sendToMain(card),
1972
2490
  onClose: () => actions.close(card),
@@ -1977,13 +2495,27 @@ window.__ModuleLoader__.load({
1977
2495
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
1978
2496
  function OverlayRoot({ actions }) {
1979
2497
  const state = useStoreState()
2498
+ // §4.5 a non-leader copy mounts no interaction surface at all (its
2499
+ // settings page stays available to explain the situation).
2500
+ if (state.familyLeader === false) return null
1980
2501
  const config = state.config
1981
2502
  const candidate = state.trigger
1982
2503
  const popover = state.popover
1983
- return h('div', { className: 'dsa-layer', 'data-dsa-root': '' },
2504
+ return h('div', {
2505
+ className: 'dsa-layer',
2506
+ 'data-dsa-root': '',
2507
+ // §4.5 stamps ownership so the legacy-suppression stylesheet can
2508
+ // distinguish this build's layer from an unmarked ≤1.0.x layer.
2509
+ 'data-dsa-owner': OWNER_TOKEN,
2510
+ },
1984
2511
  h(Styles, null),
1985
2512
  candidate !== null && popover === null && config.trigger !== 'shortcut'
1986
- ? 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
+ })
1987
2519
  : null,
1988
2520
  popover !== null
1989
2521
  ? h(AskPopover, {
@@ -1991,6 +2523,7 @@ window.__ModuleLoader__.load({
1991
2523
  config,
1992
2524
  onClose: () => actions.closePopover(),
1993
2525
  onSubmit: payload => actions.submit(popover.candidate, payload),
2526
+ onQuick: action => actions.quickAsk(popover.candidate, action),
1994
2527
  })
1995
2528
  : null,
1996
2529
  h(FloatingLayer, { cards: state.cards, actions }),
@@ -2046,11 +2579,95 @@ window.__ModuleLoader__.load({
2046
2579
  }, options.map(name => h('option', { key: name, value: name }, name)))
2047
2580
  }
2048
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
+
2049
2658
  const save = async () => {
2050
2659
  if (parseShortcut(draft.shortcut) === null) {
2051
2660
  setStatus(`error:${t('unsupportedTrigger')}`)
2052
2661
  return
2053
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
+ }
2054
2671
  setStatus('saving')
2055
2672
  try {
2056
2673
  await actions.saveConfig(draft)
@@ -2060,11 +2677,20 @@ window.__ModuleLoader__.load({
2060
2677
  }
2061
2678
  }
2062
2679
 
2680
+ // §4.5 a non-leader copy explains why its interaction surface is gone.
2681
+ const familyBanner = state.familyLeader === false
2682
+ ? h('div', { className: 'dsa-family-notice', role: 'status' },
2683
+ t('familySuppressed', { version: (state.familyPeers[0]?.version) ?? '?' }))
2684
+ : (state.familyLegacy === true
2685
+ ? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
2686
+ : null)
2687
+
2063
2688
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
2064
2689
  h(Styles, null),
2065
2690
  h('div', null,
2066
2691
  h('div', { className: 'dsa-title' }, t('settingsTitle')),
2067
2692
  h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
2693
+ familyBanner,
2068
2694
  h('div', { className: 'dsa-grid' },
2069
2695
  field(t('setTrigger'), select('trigger', [
2070
2696
  ['selection', t('setTriggerSelection')],
@@ -2107,6 +2733,7 @@ window.__ModuleLoader__.load({
2107
2733
  ])),
2108
2734
  field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
2109
2735
  field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
2736
+ quickActionsEditor,
2110
2737
  h('label', { className: 'dsa-check' },
2111
2738
  h('input', {
2112
2739
  type: 'checkbox',
@@ -2447,6 +3074,18 @@ window.__ModuleLoader__.load({
2447
3074
  }
2448
3075
  void runSideCard(card.id)
2449
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
+ },
2450
3089
  copy(card, element) {
2451
3090
  void copyText(card.text, element).then((result) => {
2452
3091
  toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
@@ -2487,6 +3126,25 @@ window.__ModuleLoader__.load({
2487
3126
  patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
2488
3127
  if (card.carrier === 'side') void runSideCard(card.id)
2489
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
+ },
2490
3148
  cancel(card) {
2491
3149
  const controller = controllers.get(card.id)
2492
3150
  if (controller !== undefined) {
@@ -2671,20 +3329,30 @@ window.__ModuleLoader__.load({
2671
3329
  // ── selection listeners ──────────────────────────────────────────
2672
3330
  const evaluate = () => {
2673
3331
  pendingTimer = null
3332
+ // §4.5 a non-leader copy must not surface its trigger, even if a
3333
+ // stale selection event arrives.
3334
+ if (store.state.familyLeader === false) return
2674
3335
  const config = store.state.config
2675
3336
  const candidate = readSelection()
2676
3337
  // Zone anchors: the harness stamps every slot outlet with
2677
- // `data-slot="<key>"`. A build that renders no such marker (or a
2678
- // selection made outside the frame's own tree) leaves the slot path
2679
- // empty, and then region filtering is impossible — reported once and
2680
- // relaxed rather than silently swallowing every trigger.
2681
- if (candidate !== null) {
2682
- const anchored = candidate.slotPath.length > 0
2683
- if (store.state.zoneAnchors !== anchored) {
2684
- store.set({ zoneAnchors: anchored })
2685
- if (!anchored) {
2686
- console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
2687
- }
3338
+ // `data-slot="<key>"`. This is a property of the BUILD, not of the
3339
+ // current selection — a selection on a body-mounted node (an
3340
+ // onboarding modal, another plugin's raw-DOM panel) has an empty
3341
+ // slot path even though the frame is full of anchors. Probe the
3342
+ // whole document instead, or one such selection would flip the
3343
+ // global verdict to "missing" and silently disable zone filtering.
3344
+ if (candidate !== null && store.state.zoneAnchors !== true) {
3345
+ const buildHasAnchors = candidate.slotPath.length > 0
3346
+ || (typeof document !== 'undefined' && document.querySelector('[data-slot]') !== null)
3347
+ if (buildHasAnchors) {
3348
+ if (store.state.zoneAnchors !== true) scheduleReport(0)
3349
+ store.set({ zoneAnchors: true })
3350
+ } else if (store.state.zoneAnchors === null && candidate.slotPath.length === 0) {
3351
+ // Only the build-wide miss (and never a single unanchored
3352
+ // selection) flips the verdict to unavailable.
3353
+ store.set({ zoneAnchors: false })
3354
+ scheduleReport(0)
3355
+ console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
2688
3356
  }
2689
3357
  }
2690
3358
  const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
@@ -2715,6 +3383,17 @@ window.__ModuleLoader__.load({
2715
3383
  }
2716
3384
 
2717
3385
  const onKeyDown = (event) => {
3386
+ // §4.5 only the leader answers the shortcut; otherwise two copies
3387
+ // would each preventDefault and each open their own popover.
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
+ }
2718
3397
  const config = store.state.config
2719
3398
  const facts = parseShortcut(config.shortcut)
2720
3399
  if (matchesShortcut(event, facts)) {
@@ -2725,6 +3404,18 @@ window.__ModuleLoader__.load({
2725
3404
  return
2726
3405
  }
2727
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
+ }
2728
3419
  if (event.key === 'Escape') {
2729
3420
  if (store.state.popover !== null) store.set({ popover: null })
2730
3421
  }
@@ -3140,6 +3831,7 @@ window.__ModuleLoader__.load({
3140
3831
  onCopy: element => actions.copy(card, element),
3141
3832
  onFollowUp: text => actions.followUp(card, text),
3142
3833
  onRetry: () => actions.retry(card),
3834
+ onRetryEdit: text => actions.retryEdit(card, text),
3143
3835
  onCancel: () => actions.cancel(card),
3144
3836
  onSendToMain: () => { void actions.sendToMain(card) },
3145
3837
  onClose: () => actions.close(card),
@@ -3153,6 +3845,61 @@ window.__ModuleLoader__.load({
3153
3845
  // Corrupt-but-parseable history must never block startup.
3154
3846
  console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
3155
3847
  }
3848
+
3849
+ // §4.5 family singleton: register this copy, (re)elect on every join
3850
+ // or leave, and gate the interaction surface on the verdict.
3851
+ const family = familyTable(window)
3852
+ family.join({ id: INSTANCE_ID, version: CLIENT_VERSION, at: Date.now() })
3853
+ const reelect = () => {
3854
+ const members = family.list()
3855
+ const leaderId = electFamilyLeader(members, INSTANCE_ID)
3856
+ const leader = leaderId === INSTANCE_ID
3857
+ const peers = members
3858
+ .filter(member => member.id !== INSTANCE_ID)
3859
+ .map(member => ({ id: member.id, version: member.version }))
3860
+ store.set({ familyLeader: leader, familyPeers: peers })
3861
+ scheduleReport(0)
3862
+ if (leader) detectLegacy()
3863
+ }
3864
+ const unsubscribeFamily = family.subscribe(reelect)
3865
+
3866
+ // Legacy (≤1.0.x) detection: it never joins the registry, so watch
3867
+ // the overlay tree for an unmarked dsa-layer carrying family UI. The
3868
+ // suppression stylesheet then hides it; we only need detection to
3869
+ // raise the toast/report. Repeated matches are a no-op.
3870
+ let legacyAnnounced = false
3871
+ let legacyObserver = null
3872
+ const detectLegacy = () => {
3873
+ if (store.state.familyLeader !== true || legacyAnnounced === true) return
3874
+ const root = typeof document !== 'undefined' ? document : null
3875
+ if (root === null) return
3876
+ const found = findLegacyLayers(root).length > 0
3877
+ if (found === false) return
3878
+ legacyAnnounced = true
3879
+ // Inject the suppression rule directly: the React stylesheet only
3880
+ // exists while our overlay is mounted, but the legacy layer must
3881
+ // stay hidden even during its own re-renders.
3882
+ try {
3883
+ if (root.getElementById('dsa-family-legacy-style') === null) {
3884
+ const style = root.createElement('style')
3885
+ style.id = 'dsa-family-legacy-style'
3886
+ style.textContent = '.dsa-layer:not([data-dsa-owner]){display:none!important}'
3887
+ root.head?.appendChild(style)
3888
+ }
3889
+ } catch { /* React stylesheet already carries the same rule */ }
3890
+ store.set({ familyLegacy: true })
3891
+ toast(t('familyLegacyToast'))
3892
+ scheduleReport(0)
3893
+ }
3894
+ const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
3895
+ if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
3896
+ legacyObserver = new MutationObserver(() => detectLegacy())
3897
+ try {
3898
+ legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
3899
+ } catch { /* document without a root yet — timers still cover it */ }
3900
+ }
3901
+
3902
+ reelect()
3156
3903
  void refreshState().catch(() => { /* the settings page shows the failure */ })
3157
3904
  // One report after the slot ladders have had time to settle, so the
3158
3905
  // Host ends up holding the CLIENT's real registration state.
@@ -3170,6 +3917,15 @@ window.__ModuleLoader__.load({
3170
3917
  pendingProofs.clear()
3171
3918
  if (toastTimer !== null) clearTimeout(toastTimer)
3172
3919
  if (reportTimer !== null) clearTimeout(reportTimer)
3920
+ for (const timer of legacyTimers) clearTimeout(timer)
3921
+ try {
3922
+ if (typeof document !== 'undefined' && document.getElementById) {
3923
+ document.getElementById('dsa-family-legacy-style')?.remove()
3924
+ }
3925
+ } catch { /* torn down */ }
3926
+ try { legacyObserver?.disconnect() } catch { /* already gone */ }
3927
+ try { unsubscribeFamily() } catch { /* registry torn down */ }
3928
+ try { family.leave(INSTANCE_ID) } catch { /* registry torn down */ }
3173
3929
  store.set({
3174
3930
  trigger: null,
3175
3931
  popover: null,
@@ -3211,6 +3967,12 @@ window.__ModuleLoader__.load({
3211
3967
  truncateSelection,
3212
3968
  parseShortcut,
3213
3969
  matchesShortcut,
3970
+ quickActionsOf,
3971
+ renderActionQuestion,
3972
+ validateQuickActionsDraft,
3973
+ shortcutFingerprint,
3974
+ QUICK_ACTION_BUILTINS,
3975
+ QUICK_ACTION_LIMITS,
3214
3976
  classifyZone,
3215
3977
  slotPathOf,
3216
3978
  shouldOffer,
@@ -3222,6 +3984,9 @@ window.__ModuleLoader__.load({
3222
3984
  renderRichText,
3223
3985
  pickSurface,
3224
3986
  floatPlacement,
3987
+ compareVersions,
3988
+ electFamilyLeader,
3989
+ findLegacyLayers,
3225
3990
  localizeError,
3226
3991
  errorCodes: ERROR_CODES,
3227
3992
  resolveLanguage,