dsh-sidecard-ask 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -98,6 +98,97 @@ 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
+ notifyOnDone: false,
112
+ contextMenu: true,
113
+ historyMaxCards: 10,
114
+ }
115
+
116
+ /**
117
+ * Bounds for quick-action editing, mirrored by the Host validator. The
118
+ * settings page refuses anything outside these before it ever posts.
119
+ */
120
+ const QUICK_ACTION_LIMITS = {
121
+ maxEntries: 12,
122
+ idLength: 40,
123
+ labelLength: 20,
124
+ questionLength: 500,
125
+ shortcutLength: 40,
126
+ }
127
+
128
+ /**
129
+ * Built-in quick actions. The config list only carries `builtin:<id>`;
130
+ * the localized label and question live HERE so the wire/config layers
131
+ * stay language-neutral. Questions are instructions only — the Host
132
+ * fences the selection separately, so none of them needs to quote text.
133
+ */
134
+ const QUICK_ACTION_BUILTINS = {
135
+ 'builtin:explain': { labelKey: 'qaExplain', questionKey: 'qaExplainQ' },
136
+ 'builtin:translate': { labelKey: 'qaTranslate', questionKey: 'qaTranslateQ' },
137
+ 'builtin:summarize': { labelKey: 'qaSummarize', questionKey: 'qaSummarizeQ' },
138
+ 'builtin:polish': { labelKey: 'qaPolish', questionKey: 'qaPolishQ' },
139
+ 'builtin:fix': { labelKey: 'qaFix', questionKey: 'qaFixQ' },
140
+ }
141
+
142
+ /**
143
+ * Fixed follow-up chips offered under a finished answer. They submit a
144
+ * follow-up TURN (the answer stays in history), so the questions refer to
145
+ * "the answer above" rather than restating the selection. Not user
146
+ * configurable in P1: they are four fixed, universally useful refinements.
147
+ */
148
+ const FOLLOW_UP_CHIPS = [
149
+ { labelKey: 'fuShorter', questionKey: 'fuShorterQ' },
150
+ { labelKey: 'fuLonger', questionKey: 'fuLongerQ' },
151
+ { labelKey: 'fuExample', questionKey: 'fuExampleQ' },
152
+ { labelKey: 'fuToEnglish', questionKey: 'fuToEnglishQ' },
153
+ ]
154
+
155
+ /** Resolve the fixed follow-up chips for the active language. */
156
+ function followUpChips() {
157
+ return FOLLOW_UP_CHIPS.map(meta => ({ label: t(meta.labelKey), question: t(meta.questionKey) }))
158
+ }
159
+
160
+ /**
161
+ * Format a side-answer elapsed time for the card meta line. Sub-second
162
+ * runs show as "<1s"; everything else rounds to whole seconds (and whole
163
+ * minutes past 60s). Pure so both the UI and the tests share one format.
164
+ * @param {number} ms - elapsed milliseconds.
165
+ * @returns {string}
166
+ */
167
+ function formatElapsed(ms) {
168
+ const value = Number(ms)
169
+ if (!Number.isFinite(value) || value < 0) return ''
170
+ if (value < 1000) return '<1s'
171
+ const seconds = Math.round(value / 1000)
172
+ if (seconds < 60) return `${seconds}s`
173
+ const minutes = Math.floor(seconds / 60)
174
+ const rest = seconds % 60
175
+ return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`
176
+ }
177
+
178
+ /**
179
+ * Deterministic local timestamp for the history manager: `YYYY-MM-DD
180
+ * HH:MM`. Returns '' for an absent/legacy entry (v1 entries had no
181
+ * createdAt) instead of showing 1970.
182
+ * @param {number} ts - epoch milliseconds.
183
+ * @returns {string}
184
+ */
185
+ function formatHistoryTime(ts) {
186
+ const value = Number(ts)
187
+ if (!Number.isFinite(value) || value <= 0) return ''
188
+ const date = new Date(value)
189
+ const pad = n => String(n).padStart(2, '0')
190
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
191
+ + `${pad(date.getHours())}:${pad(date.getMinutes())}`
101
192
  }
102
193
 
103
194
  /** Zone classification: slot-key patterns the harness itself renders. */
@@ -298,6 +389,73 @@ window.__ModuleLoader__.load({
298
389
  familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
299
390
  familyLegacyDiag: '旧版共载',
300
391
  familyPeers: '同页实例',
392
+ retryEdit: '改问',
393
+ editQuestionPlaceholder: '修改问题后重新提问…',
394
+ retryAskSubmit: '重新提问',
395
+ copyCode: '复制代码',
396
+ qaExplain: '解释',
397
+ qaExplainQ: '请用简洁的语言解释这段内容的含义。',
398
+ qaTranslate: '翻译',
399
+ qaTranslateQ: '请把这段内容翻译成中文;如果原文已经是中文,则翻译成英文。只输出译文,不要加解释。',
400
+ qaSummarize: '总结',
401
+ qaSummarizeQ: '请用简短的要点总结这段内容。',
402
+ qaPolish: '润色',
403
+ qaPolishQ: '请润色这段文字:保持原意、修正语病与不通顺之处,只输出润色后的内容。',
404
+ qaFix: '纠错',
405
+ qaFixQ: '请检查这段内容中的事实性、逻辑性与文字错误,逐条列出问题并给出修正建议。',
406
+ setQuickActions: '快捷动作',
407
+ setQuickActionsHint: '选中文字后显示的一键动作。自定义问题中的 {selection} 会替换为选中内容;不写占位符时,原文会随问题自动附上。',
408
+ qaEnabled: '启用',
409
+ qaAdd: '+ 添加动作',
410
+ qaDelete: '删除',
411
+ qaBuiltinBadge: '内置',
412
+ qaLabelPlaceholder: '动作名称(如:英译中)',
413
+ qaQuestionPlaceholder: '问题指令,可用 {selection} 引用选中内容',
414
+ qaShortcutPlaceholder: '如 Alt+T,留空表示无快捷键',
415
+ qaTooMany: '快捷动作最多 {n} 条',
416
+ qaMalformed: '存在格式不正确的动作',
417
+ qaBadId: '动作 id 缺失或超长',
418
+ qaDuplicateId: '存在重复的动作 id',
419
+ qaBadLabel: '动作名称必填,且不超过 {n} 个字符',
420
+ qaBadQuestion: '问题指令必填,且不超过 {n} 个字符',
421
+ qaBadShortcut: '“{label}”的快捷键格式不正确,请用 Alt+Q 这样的写法',
422
+ qaDuplicateShortcut: '“{label}”的快捷键与“{other}”冲突',
423
+ fuShorter: '更简短',
424
+ fuShorterQ: '请用更简短的方式重新回答上面的问题,保留关键信息。',
425
+ fuLonger: '更详细',
426
+ fuLongerQ: '请更详细地展开回答上面的问题,补充必要的细节。',
427
+ fuExample: '举个例子',
428
+ fuExampleQ: '请结合一个具体的例子来说明上面的回答。',
429
+ fuToEnglish: '译成英文',
430
+ fuToEnglishQ: 'Please give the same answer in English.',
431
+ metaElapsed: '耗时',
432
+ metaProvider: '模型',
433
+ setNotifyOnDone: '完成后桌面通知',
434
+ setNotifyOnDoneHint: '仅当页面切到后台(其它标签页或窗口)时,作答结束才弹系统通知;首次启用需要浏览器通知权限',
435
+ notifyGranted: '已获得通知权限',
436
+ notifyDenied: '通知权限被拒绝,可在浏览器站点设置中开启',
437
+ notifyUnsupported: '当前环境不支持系统通知',
438
+ notifyDoneTitle: '侧边追问已完成',
439
+ notifyErrorTitle: '侧边追问失败',
440
+ setContextMenu: '选区右键菜单',
441
+ setContextMenuHint: '在可触发区域右键选中文字时,菜单顶部显示追问与快捷动作;若与宿主自身右键菜单冲突可关闭',
442
+ setHistoryMax: '历史卡片保存数量',
443
+ setHistoryMaxHint: '0 = 不保存;上限 50 张,单卡文本最多 64K',
444
+ historyTitle: '历史追问卡片',
445
+ historyDesc: '已完成的侧边卡片保存在本浏览器中,可搜索、恢复到浮层或删除;清空浏览器数据会一并删除。',
446
+ historySearch: '搜索问题或答案…',
447
+ historyEmpty: '还没有历史卡片',
448
+ historyRestore: '恢复',
449
+ historyDelete: '删除',
450
+ historyClear: '清空全部',
451
+ historyClearConfirm: '确定清空全部历史卡片吗?此操作不可撤销。',
452
+ historyClearDone: '历史已清空',
453
+ historyRestored: '已恢复到浮层',
454
+ historyCount: '{n} 条',
455
+ historyDeleted: '已删除',
456
+ cardKeysHint: '键盘:R 重试 · C 复制 · Esc 取消聚焦',
457
+ familyLegacyDiag: '旧版共载',
458
+ familyPeers: '同页实例',
301
459
  },
302
460
  en: {
303
461
  triggerLabel: 'Ask about selection',
@@ -438,6 +596,71 @@ window.__ModuleLoader__.load({
438
596
  familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
439
597
  familyLegacyDiag: 'Legacy co-load',
440
598
  familyPeers: 'Copies on page',
599
+ retryEdit: 'Edit & retry',
600
+ editQuestionPlaceholder: 'Edit the question, then ask again…',
601
+ retryAskSubmit: 'Ask again',
602
+ copyCode: 'Copy code',
603
+ qaExplain: 'Explain',
604
+ qaExplainQ: 'Explain what this passage means, concisely.',
605
+ qaTranslate: 'Translate',
606
+ qaTranslateQ: 'Translate this passage into English; if it is already in English, translate it into Chinese. Output the translation only, with no explanation.',
607
+ qaSummarize: 'Summarize',
608
+ qaSummarizeQ: 'Summarize the key points of this passage briefly.',
609
+ qaPolish: 'Polish',
610
+ qaPolishQ: 'Polish this text: keep the original meaning, fix grammar and awkward phrasing, and output only the polished version.',
611
+ qaFix: 'Proofread',
612
+ qaFixQ: 'Check this passage for factual, logical, and writing errors. List each issue with a suggested fix.',
613
+ setQuickActions: 'Quick actions',
614
+ 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.',
615
+ qaEnabled: 'On',
616
+ qaAdd: '+ Add action',
617
+ qaDelete: 'Delete',
618
+ qaBuiltinBadge: 'built-in',
619
+ qaLabelPlaceholder: 'Action name (e.g. EN→ZH)',
620
+ qaQuestionPlaceholder: 'Question; use {selection} for the selected text',
621
+ qaShortcutPlaceholder: 'e.g. Alt+T; blank for none',
622
+ qaTooMany: 'At most {n} quick actions',
623
+ qaMalformed: 'A malformed action exists',
624
+ qaBadId: 'An action id is missing or too long',
625
+ qaDuplicateId: 'Duplicate action ids exist',
626
+ qaBadLabel: 'Action names are required and at most {n} characters',
627
+ qaBadQuestion: 'Questions are required and at most {n} characters',
628
+ qaBadShortcut: 'The shortcut for "{label}" is invalid; use a form like Alt+Q',
629
+ qaDuplicateShortcut: 'The shortcut for "{label}" conflicts with "{other}"',
630
+ fuShorter: 'Shorter',
631
+ fuShorterQ: 'Answer the question above again, more briefly, keeping the key points.',
632
+ fuLonger: 'More detail',
633
+ fuLongerQ: 'Expand the answer above with more detail where it helps.',
634
+ fuExample: 'Give an example',
635
+ fuExampleQ: 'Illustrate the answer above with one concrete example.',
636
+ fuToEnglish: 'In English',
637
+ fuToEnglishQ: 'Please give the same answer in English.',
638
+ metaElapsed: 'Elapsed',
639
+ metaProvider: 'Model',
640
+ setNotifyOnDone: 'Notify when done',
641
+ setNotifyOnDoneHint: 'Only fires a system notification when the page is in the background (another tab or window) and the answer finishes; enabling asks for the browser notification permission once',
642
+ notifyGranted: 'Notification permission granted',
643
+ notifyDenied: 'Notification permission denied; enable it in the browser site settings',
644
+ notifyUnsupported: 'This environment does not support system notifications',
645
+ notifyDoneTitle: 'Side answer done',
646
+ notifyErrorTitle: 'Side answer failed',
647
+ setContextMenu: 'Selection context menu',
648
+ setContextMenuHint: 'Right-clicking a qualifying selection shows Ask plus quick actions at the top of the menu; turn off if it conflicts with the host own menu',
649
+ setHistoryMax: 'Saved history cards',
650
+ setHistoryMaxHint: '0 = do not save; up to 50 cards, at most 64K text each',
651
+ historyTitle: 'History cards',
652
+ historyDesc: 'Finished side cards are kept in this browser; search, restore to the floating layer, or delete them. Clearing browser data removes them too.',
653
+ historySearch: 'Search questions or answers…',
654
+ historyEmpty: 'No history cards yet',
655
+ historyRestore: 'Restore',
656
+ historyDelete: 'Delete',
657
+ historyClear: 'Clear all',
658
+ historyClearConfirm: 'Clear every history card? This cannot be undone.',
659
+ historyClearDone: 'History cleared',
660
+ historyRestored: 'Restored to the floating layer',
661
+ historyCount: '{n} cards',
662
+ historyDeleted: 'Deleted',
663
+ cardKeysHint: 'Keys: R retry · C copy · Esc unfocus',
441
664
  },
442
665
  }
443
666
 
@@ -661,7 +884,14 @@ window.__ModuleLoader__.load({
661
884
  sessionId: null,
662
885
  trigger: null,
663
886
  popover: null,
887
+ /** The custom selection context menu: {candidate, x, y} or null. */
888
+ contextMenu: null,
664
889
  cards: [],
890
+ /**
891
+ * The durable card history (localStorage v2), mirrored into the store
892
+ * so the settings-page manager can render/search it without IO.
893
+ */
894
+ history: [],
665
895
  toast: null,
666
896
  /** Whether the harness renders the `data-slot` zone anchors (null = unknown yet). */
667
897
  zoneAnchors: null,
@@ -1028,6 +1258,212 @@ window.__ModuleLoader__.load({
1028
1258
  && event.metaKey === facts.meta
1029
1259
  }
1030
1260
 
1261
+ /**
1262
+ * Resolve the configured quick actions into renderable, ENABLED entries.
1263
+ * The Host validator keeps a saved list well-formed; this is the
1264
+ * render-time guard for an in-memory/edited draft: unknown `builtin:*`
1265
+ * ids and custom entries missing label/question are skipped.
1266
+ * @param {object|Array} configOrList - the effective config or a raw list.
1267
+ * @returns {Array<{id:string,label:string,question:string,shortcut:string,builtin:boolean}>}
1268
+ */
1269
+ function quickActionsOf(configOrList) {
1270
+ const list = Array.isArray(configOrList) ? configOrList : configOrList?.quickActions
1271
+ if (!Array.isArray(list)) return []
1272
+ const out = []
1273
+ for (const entry of list) {
1274
+ if (entry === null || typeof entry !== 'object') continue
1275
+ if (entry.enabled === false) continue
1276
+ const id = String(entry.id ?? '')
1277
+ const meta = QUICK_ACTION_BUILTINS[id]
1278
+ if (meta !== undefined) {
1279
+ out.push({
1280
+ id,
1281
+ label: t(meta.labelKey),
1282
+ question: t(meta.questionKey),
1283
+ shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
1284
+ builtin: true,
1285
+ })
1286
+ continue
1287
+ }
1288
+ // An unknown builtin id resolves to nothing the client knows.
1289
+ if (id.startsWith('builtin:')) continue
1290
+ if (typeof entry.label === 'string' && entry.label.trim() !== ''
1291
+ && typeof entry.question === 'string' && entry.question.trim() !== '') {
1292
+ out.push({
1293
+ id,
1294
+ label: entry.label,
1295
+ question: entry.question,
1296
+ shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
1297
+ builtin: false,
1298
+ })
1299
+ }
1300
+ }
1301
+ return out
1302
+ }
1303
+
1304
+ /**
1305
+ * The question actually sent for one quick action. A template containing
1306
+ * `{selection}` embeds the (truncated) selection inline; a template
1307
+ * without it is sent as-is, because the Host fences the selection in
1308
+ * every prompt regardless.
1309
+ * @param {{question?: string}} action - a resolved quick action.
1310
+ * @param {string} selection - the raw selected text.
1311
+ * @param {number} maxChars - the selection truncation budget.
1312
+ * @returns {string}
1313
+ */
1314
+ function renderActionQuestion(action, selection, maxChars) {
1315
+ const template = String(action?.question ?? '')
1316
+ if (!template.includes('{selection}')) return template
1317
+ const cut = truncateSelection(String(selection ?? ''), maxChars)
1318
+ return template.split('{selection}').join(cut.text)
1319
+ }
1320
+
1321
+ /** Canonical fingerprint of an accelerator, for duplicate detection. */
1322
+ function shortcutFingerprint(accelerator) {
1323
+ const facts = parseShortcut(accelerator)
1324
+ if (facts === null) return null
1325
+ return `${facts.ctrl ? 1 : 0}|${facts.alt ? 1 : 0}|${facts.shift ? 1 : 0}|${facts.meta ? 1 : 0}|${facts.key}`
1326
+ }
1327
+
1328
+ /** Whether this browser exposes the Notification API at all. */
1329
+ function notificationSupported() {
1330
+ return typeof window !== 'undefined' && typeof window.Notification === 'function'
1331
+ }
1332
+
1333
+ /** The Notification permission flag, or 'unsupported' in a bare WebView. */
1334
+ function notificationPermission() {
1335
+ if (!notificationSupported()) return 'unsupported'
1336
+ const permission = window.Notification.permission
1337
+ return typeof permission === 'string' ? permission : 'default'
1338
+ }
1339
+
1340
+ /**
1341
+ * Whether a finished run should raise a desktop notification right now.
1342
+ * All four conditions must hold: the feature is enabled, the page is in
1343
+ * the BACKGROUND (notifying a visible page is just noise), the browser
1344
+ * granted permission, and this card has not been notified yet.
1345
+ * @param {object} config - effective config.
1346
+ * @param {boolean} hidden - document.hidden at the terminal moment.
1347
+ * @param {string} permission - notificationPermission() result.
1348
+ * @param {boolean} alreadyNotified - guard against duplicate notices.
1349
+ */
1350
+ function shouldNotifyFinish(config, hidden, permission, alreadyNotified) {
1351
+ return config?.notifyOnDone === true
1352
+ && hidden === true
1353
+ && permission === 'granted'
1354
+ && alreadyNotified !== true
1355
+ }
1356
+
1357
+ /** Title/body for one card's finish notification (error vs done). */
1358
+ function buildFinishNotification(card) {
1359
+ const title = card?.status === 'error' ? t('notifyErrorTitle') : t('notifyDoneTitle')
1360
+ const question = String(card?.question ?? '').trim()
1361
+ return { title, body: Array.from(question).slice(0, 80).join('') }
1362
+ }
1363
+
1364
+ /**
1365
+ * Request the Notification permission once. Resolves to the resulting
1366
+ * permission string; a rejecting/absent API degrades to 'denied' so the
1367
+ * caller can render the toggle honestly without throwing.
1368
+ */
1369
+ async function requestNotificationPermission() {
1370
+ if (!notificationSupported()) return 'unsupported'
1371
+ try {
1372
+ const result = await window.Notification.requestPermission()
1373
+ return typeof result === 'string' ? result : notificationPermission()
1374
+ } catch {
1375
+ // Older Safari used a callback-only API; treat a thrown promise as a
1376
+ // refusal rather than breaking the settings page.
1377
+ return notificationPermission() === 'granted' ? 'granted' : 'denied'
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Fire one notification for a finished card. Returns false when the
1383
+ * feature/permission/hidden guard fails, so the caller can stay silent.
1384
+ * Clicking the notification brings the tab forward.
1385
+ */
1386
+ function notifyCardFinish(card) {
1387
+ const hidden = typeof document !== 'undefined' && document.hidden === true
1388
+ if (!shouldNotifyFinish(store.state.config, hidden, notificationPermission(), card.notified === true)) {
1389
+ return false
1390
+ }
1391
+ try {
1392
+ const { title, body } = buildFinishNotification(card)
1393
+ const notification = new window.Notification(title, { body, tag: `dsh-sidecard-ask:${card.id}` })
1394
+ notification.onclick = () => {
1395
+ try { window.focus() } catch { /* the WebView may refuse focus */ }
1396
+ try { notification.close() } catch { /* already closed */ }
1397
+ }
1398
+ patchCard(card.id, { notified: true })
1399
+ return true
1400
+ } catch (error) {
1401
+ console.warn(`[${PLUGIN_ID}] notification failed:`, error)
1402
+ return false
1403
+ }
1404
+ }
1405
+
1406
+ /**
1407
+ * Validate a settings-page quick-action draft BEFORE posting it, so the
1408
+ * Host's all-or-nothing refusal never comes back as a surprise. Also
1409
+ * checks that no action shortcut collides with another action or with the
1410
+ * main popover shortcut.
1411
+ * @param {unknown} list - the draft `quickActions` array.
1412
+ * @param {string} mainShortcut - the configured popover shortcut.
1413
+ * @returns {{ok: true}|{ok: false, key: string, vars?: object}}
1414
+ */
1415
+ function validateQuickActionsDraft(list, mainShortcut) {
1416
+ if (!Array.isArray(list) || list.length > QUICK_ACTION_LIMITS.maxEntries) {
1417
+ return { ok: false, key: 'qaTooMany' }
1418
+ }
1419
+ const ids = new Set()
1420
+ const taken = new Map()
1421
+ const mainFingerprint = shortcutFingerprint(mainShortcut)
1422
+ if (mainFingerprint !== null) taken.set(mainFingerprint, '')
1423
+ const labelOf = (entry) => {
1424
+ const meta = QUICK_ACTION_BUILTINS[String(entry.id ?? '')]
1425
+ return meta !== undefined ? t(meta.labelKey) : String(entry.label ?? '')
1426
+ }
1427
+ for (const entry of list) {
1428
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
1429
+ return { ok: false, key: 'qaMalformed' }
1430
+ }
1431
+ const id = String(entry.id ?? '')
1432
+ if (id === '' || id.length > QUICK_ACTION_LIMITS.idLength) return { ok: false, key: 'qaBadId' }
1433
+ if (ids.has(id)) return { ok: false, key: 'qaDuplicateId' }
1434
+ ids.add(id)
1435
+ const meta = QUICK_ACTION_BUILTINS[id]
1436
+ const label = labelOf(entry)
1437
+ if (meta === undefined && !id.startsWith('builtin:')) {
1438
+ const customLabel = String(entry.label ?? '')
1439
+ const customQuestion = String(entry.question ?? '')
1440
+ if (customLabel.trim() === '' || customLabel.length > QUICK_ACTION_LIMITS.labelLength) {
1441
+ return { ok: false, key: 'qaBadLabel', vars: { n: QUICK_ACTION_LIMITS.labelLength } }
1442
+ }
1443
+ if (customQuestion.trim() === '' || customQuestion.length > QUICK_ACTION_LIMITS.questionLength) {
1444
+ return { ok: false, key: 'qaBadQuestion', vars: { n: QUICK_ACTION_LIMITS.questionLength } }
1445
+ }
1446
+ }
1447
+ const shortcut = String(entry.shortcut ?? '').trim()
1448
+ if (shortcut !== '') {
1449
+ if (parseShortcut(shortcut) === null) {
1450
+ return { ok: false, key: 'qaBadShortcut', vars: { label } }
1451
+ }
1452
+ const fingerprint = shortcutFingerprint(shortcut)
1453
+ if (taken.has(fingerprint)) {
1454
+ const owner = taken.get(fingerprint)
1455
+ return {
1456
+ ok: false,
1457
+ key: 'qaDuplicateShortcut',
1458
+ vars: { label, other: owner === '' ? t('setShortcut') : owner },
1459
+ }
1460
+ }
1461
+ taken.set(fingerprint, label)
1462
+ }
1463
+ }
1464
+ return { ok: true }
1465
+ }
1466
+
1031
1467
  // ─────────────────────────────────────────────────────────────────────
1032
1468
  // §6 carriers
1033
1469
  // ─────────────────────────────────────────────────────────────────────
@@ -1363,7 +1799,7 @@ window.__ModuleLoader__.load({
1363
1799
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1364
1800
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1365
1801
  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}
1802
+ .dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-ctxmenu,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1367
1803
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1368
1804
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1369
1805
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -1372,6 +1808,41 @@ window.__ModuleLoader__.load({
1372
1808
  font-family:inherit;white-space:nowrap}
1373
1809
  .dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1374
1810
  .dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1811
+ /* The one-click action bar: the trigger button plus compact chips, laid out
1812
+ in one wrapping row under the selection. The container is positioned by the
1813
+ component; chips keep their own pointer-events because .dsa-layer is a
1814
+ click-through layer. */
1815
+ .dsa-quickbar{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:6px;
1816
+ max-width:min(620px, 92vw);flex-wrap:wrap;z-index:60}
1817
+ .dsa-chip{pointer-events:auto;padding:3px 10px;border-radius:999px;
1818
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1819
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1820
+ font:inherit;font-size:12px;line-height:16px;cursor:pointer;white-space:nowrap;
1821
+ box-shadow:0 2px 8px rgba(0,0,0,.14)}
1822
+ .dsa-chip:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1823
+ .dsa-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1824
+ /* Chips rendered INSIDE the popover need no floating shadow. */
1825
+ .dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
1826
+ .dsa-pop-chips .dsa-chip{box-shadow:none}
1827
+ /* Post-answer follow-up chips live inside a card: quiet, pill-shaped, no
1828
+ floating shadow; the row is shown only for a terminal answer with text. */
1829
+ .dsa-fu-chips{display:flex;gap:6px;flex-wrap:wrap}
1830
+ .dsa-fu-chips .dsa-chip{box-shadow:none;padding:2px 9px;font-size:11px;line-height:15px}
1831
+ /* The selection context menu: a compact vertical list at the cursor. */
1832
+ .dsa-ctxmenu{pointer-events:auto;position:fixed;z-index:65;display:flex;flex-direction:column;
1833
+ min-width:168px;padding:4px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1834
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1835
+ box-shadow:0 10px 30px rgba(0,0,0,.24);font-size:13px}
1836
+ .dsa-ctx-item{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;
1837
+ border:none;border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;
1838
+ line-height:17px;cursor:pointer;white-space:nowrap}
1839
+ .dsa-ctx-item:hover,.dsa-ctx-item:focus-visible{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));outline:none}
1840
+ .dsa-ctx-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1, rgba(127,127,127,.25))}
1841
+ .dsa-ctx-badge{margin-left:auto;font-size:10px;color:var(--dsw-alias-label-secondary, #888)}
1842
+ /* Inside the trigger bar the main button and chips are one flex row: the
1843
+ trigger drops its own fixed positioning, chips join the row directly. */
1844
+ .dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
1845
+ .dsa-quickbar-chips{display:contents}
1375
1846
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
1376
1847
  padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1377
1848
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -1456,6 +1927,11 @@ window.__ModuleLoader__.load({
1456
1927
  .dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1457
1928
  padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
1458
1929
  .dsa-table th{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-weight:600}
1930
+ .dsa-pre-wrap{position:relative;margin:0 0 6px}
1931
+ .dsa-pre-wrap .dsa-pre{margin:0}
1932
+ .dsa-code-copy{position:absolute;top:4px;right:4px;padding:1px 7px;line-height:16px;
1933
+ font-size:12px;opacity:.72;background:var(--dsw-alias-bg-overlay, #fff)}
1934
+ .dsa-code-copy:hover,.dsa-code-copy:focus-visible{opacity:1}
1459
1935
  .dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
1460
1936
  background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
1461
1937
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -1474,6 +1950,22 @@ window.__ModuleLoader__.load({
1474
1950
  background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
1475
1951
  .dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
1476
1952
  .dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
1953
+ /* Quick-action editor (settings page): one wrapped row per action. */
1954
+ .dsa-qa{display:flex;flex-direction:column;gap:8px}
1955
+ .dsa-qa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
1956
+ padding:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px}
1957
+ .dsa-qa-row .dsa-input{flex:1;min-width:120px}
1958
+ .dsa-qa-question{flex-basis:100%!important;min-width:0!important}
1959
+ .dsa-qa-head{display:flex;align-items:center;gap:8px;font-size:12px;
1960
+ color:var(--dsw-alias-label-secondary, #555)}
1961
+ /* History-card manager (settings page). */
1962
+ .dsa-hist{display:flex;flex-direction:column;gap:8px}
1963
+ .dsa-hist-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
1964
+ padding:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px}
1965
+ .dsa-hist-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
1966
+ .dsa-hist-q{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
1967
+ color:var(--dsw-alias-label-primary, #111)}
1968
+ .dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
1477
1969
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
1478
1970
  color:var(--dsw-alias-label-secondary, #555)}
1479
1971
  /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
@@ -1559,7 +2051,33 @@ window.__ModuleLoader__.load({
1559
2051
  i += 1
1560
2052
  }
1561
2053
  i += 1
1562
- nodes.push(h('pre', { key: `f${key++}`, className: 'dsa-pre' }, h('code', null, body.join('\n'))))
2054
+ const code = body.join('\n')
2055
+ // A per-block copy button: answers are code-heavy, and dragging a
2056
+ // selection across a scrollable <pre> used to miss lines. The
2057
+ // click closes over the code text (never re-read from the DOM), and
2058
+ // reuses the same clipboard API → selection fallback as card copy.
2059
+ nodes.push(h('div', { key: `f${key++}`, className: 'dsa-pre-wrap' },
2060
+ h('pre', { className: 'dsa-pre' }, h('code', null, code)),
2061
+ h('button', {
2062
+ type: 'button',
2063
+ className: 'dsa-btn dsa-btn-icon dsa-code-copy',
2064
+ title: t('copyCode'),
2065
+ 'aria-label': t('copyCode'),
2066
+ onClick: (event) => {
2067
+ try {
2068
+ const host = event?.currentTarget ?? event?.target
2069
+ const wrap = typeof host?.closest === 'function' ? host.closest('.dsa-pre-wrap') : null
2070
+ const codeNode = wrap !== null && typeof wrap?.querySelector === 'function'
2071
+ ? wrap.querySelector('code')
2072
+ : null
2073
+ void copyText(code, codeNode).then((result) => {
2074
+ toast(result === 'copied'
2075
+ ? t('copied')
2076
+ : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
2077
+ })
2078
+ } catch { /* a clipboard failure must not break the card */ }
2079
+ },
2080
+ }, '⧉')))
1563
2081
  continue
1564
2082
  }
1565
2083
  if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
@@ -1706,15 +2224,15 @@ window.__ModuleLoader__.load({
1706
2224
  // ─────────────────────────────────────────────────────────────────────
1707
2225
 
1708
2226
  /** The in-place trigger button floating at the selection. */
1709
- function TriggerButton({ candidate, onOpen }) {
2227
+ function TriggerButton({ candidate, onOpen, embedded = false }) {
1710
2228
  // The clamp width follows the live label: icon + gap + padding + border
1711
2229
  // (~38px of chrome) plus a safety margin, over the estimated label width.
1712
2230
  const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
1713
- const box = clampBox(candidate.rect, width, 30, 'below')
2231
+ const box = embedded ? null : clampBox(candidate.rect, width, 30, 'below')
1714
2232
  return h('button', {
1715
2233
  type: 'button',
1716
- className: 'dsa-trigger',
1717
- style: { left: `${box.left}px`, top: `${box.top}px` },
2234
+ className: embedded ? 'dsa-trigger dsa-trigger-embedded' : 'dsa-trigger',
2235
+ style: embedded ? undefined : { left: `${box.left}px`, top: `${box.top}px` },
1718
2236
  title: t('triggerHint'),
1719
2237
  'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
1720
2238
  onMouseDown: (event) => { event.preventDefault() },
@@ -1726,14 +2244,141 @@ window.__ModuleLoader__.load({
1726
2244
  }, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
1727
2245
  }
1728
2246
 
2247
+ /**
2248
+ * Compact quick-action chips, shared by the in-place trigger bar and the
2249
+ * popover. The trigger bar lays chips out directly (`display:contents`);
2250
+ * the popover wraps them in its own borderless chip row.
2251
+ */
2252
+ function QuickChips({ actions, onPick, inline = false, wrapClassName }) {
2253
+ if (!Array.isArray(actions) || actions.length === 0) return null
2254
+ // Roving focus with the arrow keys: chip rows sit beside the trigger
2255
+ // button and inside the popover, and keyboard users must be able to walk
2256
+ // them without Tabbing through the whole page.
2257
+ const onChipKeyDown = (event, index) => {
2258
+ if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft'
2259
+ && event.key !== 'Home' && event.key !== 'End') return
2260
+ const siblings = typeof event.currentTarget?.parentElement?.querySelectorAll === 'function'
2261
+ ? [...event.currentTarget.parentElement.querySelectorAll('.dsa-chip')]
2262
+ : []
2263
+ if (siblings.length === 0) return
2264
+ event.preventDefault()
2265
+ let next = index
2266
+ if (event.key === 'ArrowRight') next = (index + 1) % siblings.length
2267
+ else if (event.key === 'ArrowLeft') next = (index - 1 + siblings.length) % siblings.length
2268
+ else if (event.key === 'Home') next = 0
2269
+ else next = siblings.length - 1
2270
+ siblings[next]?.focus?.()
2271
+ }
2272
+ return h('div', { className: wrapClassName ?? (inline ? 'dsa-quickbar-chips' : 'dsa-pop-chips') },
2273
+ actions.map((action, index) => h('button', {
2274
+ key: action.id,
2275
+ type: 'button',
2276
+ className: 'dsa-chip',
2277
+ title: typeof action.shortcut === 'string' && action.shortcut.trim() !== ''
2278
+ ? `${action.question}\n${action.shortcut}`
2279
+ : action.question,
2280
+ 'aria-label': `${action.label}:${action.question}`,
2281
+ onMouseDown: (event) => { event.preventDefault() },
2282
+ onKeyDown: event => onChipKeyDown(event, index),
2283
+ onClick: (event) => {
2284
+ event.preventDefault()
2285
+ event.stopPropagation()
2286
+ onPick(action)
2287
+ },
2288
+ }, action.label)))
2289
+ }
2290
+
2291
+ /**
2292
+ * The in-place trigger bar: the main "ask" button followed by every
2293
+ * enabled quick action. A chip click skips the popover entirely — the
2294
+ * question is fixed and the answer goes straight to the side card.
2295
+ */
2296
+ function TriggerBar({ candidate, config, onOpen, onQuick }) {
2297
+ const actions = quickActionsOf(config)
2298
+ const mainWidth = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
2299
+ // Rough row width for viewport clamping: main button + 6px gap + an
2300
+ // estimate per chip (label + ~24px padding/chrome). Wrapped rows cost
2301
+ // height, not width, so the clamp stays usable even when it wraps.
2302
+ const chipsWidth = actions.reduce(
2303
+ (sum, action) => sum + Math.ceil(estimateLabelWidth(action.label) + 24), 0)
2304
+ const rowWidth = Math.min(620, mainWidth + (actions.length > 0 ? 6 + chipsWidth : 0))
2305
+ const box = clampBox(candidate.rect, rowWidth, 34, 'below')
2306
+ return h('div', {
2307
+ className: 'dsa-quickbar',
2308
+ style: { left: `${box.left}px`, top: `${box.top}px` },
2309
+ onMouseDown: event => event.stopPropagation(),
2310
+ },
2311
+ h(TriggerButton, { candidate, onOpen, embedded: true }),
2312
+ h(QuickChips, { actions, onPick: onQuick, inline: true }))
2313
+ }
2314
+
2315
+ /**
2316
+ * The selection context menu (#8). Opens at the cursor with the main Ask
2317
+ * entry plus the FIRST THREE enabled quick actions (kept short so it
2318
+ * never grows past a normal context menu). This is a capability-gated
2319
+ * overlay: the listener only suppresses the native menu when config
2320
+ * allows it AND the candidate passes the same shouldOffer() gate as the
2321
+ * trigger button.
2322
+ */
2323
+ const CONTEXT_MENU_ACTION_LIMIT = 3
2324
+ function SelectionContextMenu({ candidate, position, config, onClose, onOpen, onQuick }) {
2325
+ const actions = quickActionsOf(config).slice(0, CONTEXT_MENU_ACTION_LIMIT)
2326
+ // Cursor rect: clampBox reads left/top/right/bottom.
2327
+ const point = {
2328
+ left: position?.x ?? 0,
2329
+ top: position?.y ?? 0,
2330
+ right: position?.x ?? 0,
2331
+ bottom: position?.y ?? 0,
2332
+ width: 0,
2333
+ height: 0,
2334
+ }
2335
+ const box = clampBox(point, 200, 40 + actions.length * 31, 'below')
2336
+ const item = (glyph, label, onClick, badge) => h('button', {
2337
+ type: 'button',
2338
+ className: 'dsa-ctx-item',
2339
+ onMouseDown: event => event.preventDefault(),
2340
+ onClick: (event) => {
2341
+ event.preventDefault()
2342
+ event.stopPropagation()
2343
+ onClose()
2344
+ onClick()
2345
+ },
2346
+ }, h('span', { 'aria-hidden': true }, glyph), h('span', null, label),
2347
+ badge !== undefined ? h('span', { className: 'dsa-ctx-badge' }, badge) : null)
2348
+ return h('div', {
2349
+ className: 'dsa-ctxmenu',
2350
+ role: 'menu',
2351
+ 'aria-label': t('askTitle'),
2352
+ style: { left: `${box.left}px`, top: `${box.top}px` },
2353
+ onMouseDown: event => event.stopPropagation(),
2354
+ onKeyDown: (event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose() } },
2355
+ },
2356
+ item('💬', t('triggerLabel'), () => onOpen(candidate), zoneLabel(candidate)),
2357
+ actions.length > 0 ? h('div', { className: 'dsa-ctx-sep', 'aria-hidden': true }) : null,
2358
+ actions.map(action => h('button', {
2359
+ key: action.id,
2360
+ type: 'button',
2361
+ role: 'menuitem',
2362
+ className: 'dsa-ctx-item',
2363
+ onMouseDown: event => event.preventDefault(),
2364
+ onClick: (event) => {
2365
+ event.preventDefault()
2366
+ event.stopPropagation()
2367
+ onClose()
2368
+ onQuick(action)
2369
+ },
2370
+ }, h('span', null, action.label))))
2371
+ }
2372
+
1729
2373
  /** The question popover. */
1730
- function AskPopover({ candidate, config, onClose, onSubmit }) {
2374
+ function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
1731
2375
  const [question, setQuestion] = React.useState('')
1732
2376
  const [carrier, setCarrier] = React.useState(config.defaultCarrier)
1733
2377
  const [busy, setBusy] = React.useState(false)
1734
2378
  const areaRef = React.useRef(null)
1735
2379
  const cut = truncateSelection(candidate.text, config.maxChars)
1736
- const box = clampBox(candidate.rect, 420, 260, 'below')
2380
+ const box = clampBox(candidate.rect, 420, 300, 'below')
2381
+ const quickActions = quickActionsOf(config)
1737
2382
 
1738
2383
  React.useEffect(() => {
1739
2384
  const area = areaRef.current
@@ -1763,6 +2408,19 @@ window.__ModuleLoader__.load({
1763
2408
  onClose()
1764
2409
  return
1765
2410
  }
2411
+ // Alt+1..9 fires the Nth chip without leaving the keyboard. The alt
2412
+ // modifier keeps it from colliding with ordinary digit input, and a
2413
+ // composing IME sequence must pass through untouched.
2414
+ if (event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing
2415
+ && /^[1-9]$/.test(event.key)) {
2416
+ const index = Number(event.key) - 1
2417
+ const action = quickActions[index]
2418
+ if (action !== undefined) {
2419
+ event.preventDefault()
2420
+ onQuick(action)
2421
+ }
2422
+ return
2423
+ }
1766
2424
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
1767
2425
  event.preventDefault()
1768
2426
  void submit()
@@ -1780,6 +2438,10 @@ window.__ModuleLoader__.load({
1780
2438
  codePointLength(candidate.text) > 400
1781
2439
  ? `${Array.from(candidate.text).slice(0, 400).join('')}…`
1782
2440
  : candidate.text),
2441
+ // Same chips as the in-place bar: shortcut-only users reach them via
2442
+ // Alt+Q, and a click here submits directly (the popover closes because
2443
+ // submit/quickAsk both clear `popover`).
2444
+ h(QuickChips, { actions: quickActions, onPick: action => onQuick(action) }),
1783
2445
  h('textarea', {
1784
2446
  ref: areaRef,
1785
2447
  className: 'dsa-textarea',
@@ -1854,8 +2516,8 @@ window.__ModuleLoader__.load({
1854
2516
  reasoning, renderRichText(card.text))
1855
2517
  }
1856
2518
 
1857
- /** Card footer: status, copy, follow-up, retry, close. */
1858
- function CardActions({ card, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
2519
+ /** Card footer: status, copy, follow-up, retry/edit-retry, close. */
2520
+ function CardActions({ card, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain }) {
1859
2521
  const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
1860
2522
  return h('div', { className: 'dsa-row' },
1861
2523
  h('span', { className: 'dsa-muted' },
@@ -1865,9 +2527,15 @@ window.__ModuleLoader__.load({
1865
2527
  card.status === 'streaming'
1866
2528
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
1867
2529
  : null,
1868
- terminal && card.status !== 'done'
2530
+ terminal && card.status !== 'done' && card.carrier === 'side'
1869
2531
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
1870
2532
  : null,
2533
+ // Edit & retry is available for EVERY terminal side card: a typo in
2534
+ // the question used to invalidate the whole card (plain retry only
2535
+ // resends verbatim), and an error card can be corrected the same way.
2536
+ terminal && card.carrier === 'side'
2537
+ ? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetryEdit }, t('retryEdit'))
2538
+ : null,
1871
2539
  card.status === 'error' && card.carrier === 'side'
1872
2540
  ? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
1873
2541
  : null,
@@ -1893,9 +2561,14 @@ window.__ModuleLoader__.load({
1893
2561
  * (the layer is the only surface that can cover the conversation). A host
1894
2562
  * tab that embeds the same card passes no callback and gets no buttons.
1895
2563
  */
1896
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
2564
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode }) {
1897
2565
  const [followOpen, setFollowOpen] = React.useState(false)
1898
2566
  const [draft, setDraft] = React.useState('')
2567
+ // Edit-&-retry editor: prefilled with the card's CURRENT question and
2568
+ // kept separate from the follow-up draft (follow-up appends a turn;
2569
+ // edit-retry regenerates THIS turn, keeping the card's earlier turns).
2570
+ const [editOpen, setEditOpen] = React.useState(false)
2571
+ const [editDraft, setEditDraft] = React.useState('')
1899
2572
  const answerRef = React.useRef(null)
1900
2573
  const config = store.state.config
1901
2574
  // Icon-only, so the header row keeps one line: the full labels
@@ -1908,7 +2581,46 @@ window.__ModuleLoader__.load({
1908
2581
  'aria-label': t(labelKey),
1909
2582
  onClick: () => onFloatMode(mode),
1910
2583
  }, glyph)
1911
- return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
2584
+ // #10: the card is keyboard-focusable; R retries a failed/stopped run,
2585
+ // C copies the answer, Esc releases focus. Plain keys are accepted only
2586
+ // without modifiers and never while composing or inside an editor.
2587
+ const onCardKeyDown = (event) => {
2588
+ if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return
2589
+ const tag = String(event.target?.tagName ?? '')
2590
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON'
2591
+ || event.target?.closest?.('[contenteditable=""],[contenteditable="true"]') !== null) {
2592
+ if (event.key === 'Escape' && tag !== 'INPUT' && tag !== 'TEXTAREA') {
2593
+ event.currentTarget?.blur?.()
2594
+ }
2595
+ return
2596
+ }
2597
+ const key = String(event.key ?? '').toLowerCase()
2598
+ if (key === 'r' && (card.status === 'error' || card.status === 'stopped')) {
2599
+ event.preventDefault()
2600
+ onRetry()
2601
+ } else if (key === 'c' && card.text !== '') {
2602
+ event.preventDefault()
2603
+ onCopy(answerRef.current)
2604
+ } else if (event.key === 'Escape') {
2605
+ event.currentTarget?.blur?.()
2606
+ }
2607
+ }
2608
+ const terminalWithAnswer = (card.status === 'done' || card.status === 'stopped')
2609
+ && card.carrier === 'side' && card.text !== ''
2610
+ // #9: which model answered and how long it took. provider arrives on the
2611
+ // start event; elapsed is measured client-side across the stream.
2612
+ const metaBits = []
2613
+ if (typeof card.provider === 'string' && card.provider !== '') metaBits.push(`${t('metaProvider')} ${card.provider}`)
2614
+ if (typeof card.elapsedMs === 'number' && Number.isFinite(card.elapsedMs) && formatElapsed(card.elapsedMs) !== '') {
2615
+ metaBits.push(`${t('metaElapsed')} ${formatElapsed(card.elapsedMs)}`)
2616
+ }
2617
+ return h('section', {
2618
+ className: 'dsa-card',
2619
+ 'aria-label': t('answerTitle'),
2620
+ tabIndex: 0,
2621
+ title: t('cardKeysHint'),
2622
+ onKeyDown: onCardKeyDown,
2623
+ },
1912
2624
  h('div', { className: 'dsa-row' },
1913
2625
  h('span', { className: 'dsa-title' }, t('answerTitle')),
1914
2626
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
@@ -1920,6 +2632,7 @@ window.__ModuleLoader__.load({
1920
2632
  floatControl('off', 'floatHide', '×'))
1921
2633
  : null),
1922
2634
  h('pre', { className: 'dsa-quote' }, card.question),
2635
+ metaBits.length > 0 ? h('div', { className: 'dsa-muted' }, metaBits.join(' · ')) : null,
1923
2636
  h(AnswerBody, { card, containerRef: answerRef }),
1924
2637
  card.toolFilter === 'unsupported'
1925
2638
  ? h('div', { className: 'dsa-muted' }, t('toolGuardUnavailable'))
@@ -1947,12 +2660,70 @@ window.__ModuleLoader__.load({
1947
2660
  },
1948
2661
  }))
1949
2662
  : null,
2663
+ editOpen
2664
+ ? h('div', { className: 'dsa-field' },
2665
+ h('textarea', {
2666
+ className: 'dsa-textarea',
2667
+ value: editDraft,
2668
+ placeholder: t('editQuestionPlaceholder'),
2669
+ autoFocus: true,
2670
+ onChange: event => setEditDraft(event.target.value),
2671
+ onKeyDown: (event) => {
2672
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
2673
+ event.preventDefault()
2674
+ const text = editDraft.trim()
2675
+ if (text === '') return
2676
+ setEditDraft('')
2677
+ setEditOpen(false)
2678
+ onRetryEdit(text)
2679
+ }
2680
+ if (event.key === 'Escape') {
2681
+ event.stopPropagation()
2682
+ setEditOpen(false)
2683
+ }
2684
+ },
2685
+ }),
2686
+ h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
2687
+ h('button', {
2688
+ type: 'button',
2689
+ className: 'dsa-btn',
2690
+ onClick: () => setEditOpen(false),
2691
+ }, t('askCancel')),
2692
+ h('button', {
2693
+ type: 'button',
2694
+ className: 'dsa-btn dsa-btn-primary',
2695
+ disabled: editDraft.trim() === '',
2696
+ onClick: () => {
2697
+ const text = editDraft.trim()
2698
+ if (text === '') return
2699
+ setEditDraft('')
2700
+ setEditOpen(false)
2701
+ onRetryEdit(text)
2702
+ },
2703
+ }, t('retryAskSubmit'))))
2704
+ : null,
2705
+ // #7: one-tap refinements of a finished answer. They submit follow-up
2706
+ // TURNS through the same handler as the textarea, so the answer stays
2707
+ // in history and each chip costs one ordinary request.
2708
+ terminalWithAnswer
2709
+ ? h(QuickChips, {
2710
+ wrapClassName: 'dsa-fu-chips',
2711
+ actions: followUpChips(),
2712
+ onPick: chip => onFollowUp(chip.question),
2713
+ })
2714
+ : null,
1950
2715
  h(CardActions, {
1951
2716
  card,
1952
2717
  onCopy: () => onCopy(answerRef.current),
1953
2718
  onRetry,
1954
2719
  onCancel,
1955
2720
  onSendToMain,
2721
+ onRetryEdit: () => {
2722
+ // Open with the live question, so reopening after an earlier edit
2723
+ // re-edits the question actually asked, not the card's first one.
2724
+ setEditDraft(card.question)
2725
+ setEditOpen(open => !open)
2726
+ },
1956
2727
  onFollowUp: () => setFollowOpen(open => !open),
1957
2728
  onClose,
1958
2729
  }),
@@ -2084,6 +2855,7 @@ window.__ModuleLoader__.load({
2084
2855
  onCopy: element => actions.copy(card, element),
2085
2856
  onFollowUp: text => actions.followUp(card, text),
2086
2857
  onRetry: () => actions.retry(card),
2858
+ onRetryEdit: text => actions.retryEdit(card, text),
2087
2859
  onCancel: () => actions.cancel(card),
2088
2860
  onSendToMain: () => actions.sendToMain(card),
2089
2861
  onClose: () => actions.close(card),
@@ -2109,7 +2881,12 @@ window.__ModuleLoader__.load({
2109
2881
  },
2110
2882
  h(Styles, null),
2111
2883
  candidate !== null && popover === null && config.trigger !== 'shortcut'
2112
- ? h(TriggerButton, { candidate, onOpen: () => actions.openPopover(candidate) })
2884
+ ? h(TriggerBar, {
2885
+ candidate,
2886
+ config,
2887
+ onOpen: () => actions.openPopover(candidate),
2888
+ onQuick: action => actions.quickAsk(candidate, action),
2889
+ })
2113
2890
  : null,
2114
2891
  popover !== null
2115
2892
  ? h(AskPopover, {
@@ -2117,6 +2894,17 @@ window.__ModuleLoader__.load({
2117
2894
  config,
2118
2895
  onClose: () => actions.closePopover(),
2119
2896
  onSubmit: payload => actions.submit(popover.candidate, payload),
2897
+ onQuick: action => actions.quickAsk(popover.candidate, action),
2898
+ })
2899
+ : null,
2900
+ state.contextMenu !== null
2901
+ ? h(SelectionContextMenu, {
2902
+ candidate: state.contextMenu.candidate,
2903
+ position: state.contextMenu.position,
2904
+ config,
2905
+ onClose: () => actions.closeContextMenu(),
2906
+ onOpen: (candidate) => actions.openPopover(candidate),
2907
+ onQuick: action => actions.quickAsk(state.contextMenu.candidate, action),
2120
2908
  })
2121
2909
  : null,
2122
2910
  h(FloatingLayer, { cards: state.cards, actions }),
@@ -2129,10 +2917,26 @@ window.__ModuleLoader__.load({
2129
2917
  const [draft, setDraft] = React.useState(state.config)
2130
2918
  const [status, setStatus] = React.useState('idle')
2131
2919
  const [diag, setDiag] = React.useState(null)
2920
+ const [historyQuery, setHistoryQuery] = React.useState('')
2921
+ const [notifyPerm, setNotifyPerm] = React.useState(
2922
+ typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
2132
2923
  const config = state.config
2133
2924
 
2134
2925
  React.useEffect(() => { setDraft(config) }, [config])
2135
2926
 
2927
+ // #5: enabling the toggle asks for the Notification permission
2928
+ // immediately, and the resulting verdict is shown beside the checkbox
2929
+ // so a denied toggle never looks silently broken.
2930
+ const onNotifyToggle = async (enabled) => {
2931
+ setDraft({ ...draft, notifyOnDone: enabled })
2932
+ if (enabled !== true) return
2933
+ if (notifyPerm === 'granted') return
2934
+ const result = await requestNotificationPermission()
2935
+ setNotifyPerm(result)
2936
+ if (result !== 'granted') toast(result === 'unsupported' ? t('notifyUnsupported') : t('notifyDenied'))
2937
+ else toast(t('notifyGranted'))
2938
+ }
2939
+
2136
2940
  const field = (label, control, hint) => h('div', { className: 'dsa-field' },
2137
2941
  h('label', null, label),
2138
2942
  control,
@@ -2172,11 +2976,95 @@ window.__ModuleLoader__.load({
2172
2976
  }, options.map(name => h('option', { key: name, value: name }, name)))
2173
2977
  }
2174
2978
 
2979
+ // ── quick-action list editor ───────────────────────────────────────
2980
+ // The draft always carries the FULL list (the user layer replaces
2981
+ // defaults wholesale): built-in rows are editable only in
2982
+ // enabled/shortcut, custom rows also in label/question.
2983
+ const qaList = () => (Array.isArray(draft.quickActions) ? draft.quickActions : [])
2984
+ const patchAction = (index, patch) => {
2985
+ const quickActions = qaList().map((entry, i) => (i === index ? { ...entry, ...patch } : entry))
2986
+ setDraft({ ...draft, quickActions })
2987
+ }
2988
+ const addAction = () => {
2989
+ const list = qaList()
2990
+ // Host cap is enforced on save; do not even append beyond it.
2991
+ if (list.length >= QUICK_ACTION_LIMITS.maxEntries) return
2992
+ const id = `custom:${Date.now().toString(36)}-${list.length + 1}`
2993
+ setDraft({ ...draft, quickActions: [...list, { id, label: '', question: '', enabled: true, shortcut: '' }] })
2994
+ }
2995
+ const removeAction = (index) => {
2996
+ setDraft({ ...draft, quickActions: qaList().filter((_, i) => i !== index) })
2997
+ }
2998
+ const quickActionsEditor = h('div', { className: 'dsa-field' },
2999
+ h('label', null, t('setQuickActions')),
3000
+ h('div', { className: 'dsa-qa' },
3001
+ qaList().map((entry, index) => {
3002
+ const meta = QUICK_ACTION_BUILTINS[String(entry.id)]
3003
+ const isBuiltin = meta !== undefined
3004
+ return h('div', { key: `${entry.id}-${index}`, className: 'dsa-qa-row' },
3005
+ h('label', { className: 'dsa-check', title: t('qaEnabled') },
3006
+ h('input', {
3007
+ type: 'checkbox',
3008
+ checked: entry.enabled !== false,
3009
+ onChange: event => patchAction(index, { enabled: event.target.checked }),
3010
+ }),
3011
+ t('qaEnabled')),
3012
+ isBuiltin ? h('span', { className: 'dsa-badge' }, t('qaBuiltinBadge')) : null,
3013
+ isBuiltin
3014
+ ? h('span', { className: 'dsa-title' }, t(meta.labelKey))
3015
+ : h('input', {
3016
+ className: 'dsa-input',
3017
+ value: String(entry.label ?? ''),
3018
+ maxLength: QUICK_ACTION_LIMITS.labelLength,
3019
+ placeholder: t('qaLabelPlaceholder'),
3020
+ onChange: event => patchAction(index, { label: event.target.value }),
3021
+ }),
3022
+ h('input', {
3023
+ className: 'dsa-input',
3024
+ style: { maxWidth: '150px' },
3025
+ value: String(entry.shortcut ?? ''),
3026
+ maxLength: QUICK_ACTION_LIMITS.shortcutLength,
3027
+ placeholder: t('qaShortcutPlaceholder'),
3028
+ onChange: event => patchAction(index, { shortcut: event.target.value }),
3029
+ }),
3030
+ isBuiltin
3031
+ ? null
3032
+ : h('button', {
3033
+ type: 'button',
3034
+ className: 'dsa-btn',
3035
+ onClick: () => removeAction(index),
3036
+ }, t('qaDelete')),
3037
+ isBuiltin
3038
+ ? h('div', { className: 'dsa-muted dsa-qa-question' }, t(meta.questionKey))
3039
+ : h('input', {
3040
+ className: 'dsa-input dsa-qa-question',
3041
+ value: String(entry.question ?? ''),
3042
+ maxLength: QUICK_ACTION_LIMITS.questionLength,
3043
+ placeholder: t('qaQuestionPlaceholder'),
3044
+ onChange: event => patchAction(index, { question: event.target.value }),
3045
+ }))
3046
+ }),
3047
+ h('div', null, h('button', {
3048
+ type: 'button',
3049
+ className: 'dsa-btn',
3050
+ disabled: qaList().length >= QUICK_ACTION_LIMITS.maxEntries,
3051
+ onClick: addAction,
3052
+ }, t('qaAdd')))),
3053
+ h('span', { className: 'dsa-muted' }, t('setQuickActionsHint')))
3054
+
2175
3055
  const save = async () => {
2176
3056
  if (parseShortcut(draft.shortcut) === null) {
2177
3057
  setStatus(`error:${t('unsupportedTrigger')}`)
2178
3058
  return
2179
3059
  }
3060
+ // Pre-flight the quick-action list: the Host would all-or-nothing
3061
+ // refuse it anyway, but a local check names the offending row instead
3062
+ // of surfacing a wire error after the save.
3063
+ const qaResult = validateQuickActionsDraft(draft.quickActions, draft.shortcut)
3064
+ if (qaResult.ok === false) {
3065
+ setStatus(`error:${t(qaResult.key, qaResult.vars)}`)
3066
+ return
3067
+ }
2180
3068
  setStatus('saving')
2181
3069
  try {
2182
3070
  await actions.saveConfig(draft)
@@ -2186,6 +3074,73 @@ window.__ModuleLoader__.load({
2186
3074
  }
2187
3075
  }
2188
3076
 
3077
+ // ── history manager (#6) ─────────────────────────────────────────
3078
+ const zoneLabelOf = zone => (zone === 'chat' ? t('zoneChat') : zone === 'task' ? t('zoneTask') : t('zoneOther'))
3079
+ const query = historyQuery.trim().toLowerCase()
3080
+ const filteredHistory = query === ''
3081
+ ? state.history
3082
+ : state.history.filter(entry =>
3083
+ String(entry.question ?? '').toLowerCase().includes(query)
3084
+ || String(entry.text ?? '').toLowerCase().includes(query))
3085
+ const historySection = h('div', { className: 'dsa-field' },
3086
+ h('label', null, `${t('historyTitle')}(${t('historyCount', { n: state.history.length })})`),
3087
+ h('span', { className: 'dsa-muted' }, t('historyDesc')),
3088
+ h('input', {
3089
+ className: 'dsa-input',
3090
+ value: historyQuery,
3091
+ placeholder: t('historySearch'),
3092
+ onChange: event => setHistoryQuery(event.target.value),
3093
+ }),
3094
+ state.history.length === 0
3095
+ ? h('div', { className: 'dsa-muted' }, t('historyEmpty'))
3096
+ : h('div', { className: 'dsa-hist' },
3097
+ filteredHistory.length === 0 && query !== ''
3098
+ ? h('div', { className: 'dsa-muted' }, t('historyEmpty'))
3099
+ : null,
3100
+ filteredHistory.map(entry => h('div', { key: entry.id, className: 'dsa-hist-row' },
3101
+ h('div', { className: 'dsa-hist-main' },
3102
+ h('div', { className: 'dsa-hist-q', title: entry.question }, entry.question),
3103
+ h('div', { className: 'dsa-hist-meta' },
3104
+ [
3105
+ zoneLabelOf(entry.zone),
3106
+ formatHistoryTime(entry.createdAt),
3107
+ typeof entry.provider === 'string' && entry.provider !== ''
3108
+ ? `${t('metaProvider')} ${entry.provider}`
3109
+ : '',
3110
+ Number.isFinite(entry.elapsedMs) ? `${t('metaElapsed')} ${formatElapsed(entry.elapsedMs)}` : '',
3111
+ ].filter(part => part !== '').join(' · '))),
3112
+ h('button', {
3113
+ type: 'button',
3114
+ className: 'dsa-btn',
3115
+ onClick: () => actions.restoreHistoryCard(entry.id),
3116
+ }, t('historyRestore')),
3117
+ h('button', {
3118
+ type: 'button',
3119
+ className: 'dsa-btn',
3120
+ onClick: () => {
3121
+ void copyText(String(entry.text ?? '')).then((result) => {
3122
+ toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
3123
+ })
3124
+ },
3125
+ }, t('copy')),
3126
+ h('button', {
3127
+ type: 'button',
3128
+ className: 'dsa-btn',
3129
+ onClick: () => actions.deleteHistoryCard(entry.id),
3130
+ }, t('historyDelete')))),
3131
+ state.history.length > 0
3132
+ ? h('div', null, h('button', {
3133
+ type: 'button',
3134
+ className: 'dsa-btn',
3135
+ onClick: () => {
3136
+ // confirm() is optional in embedded WebViews; when absent,
3137
+ // clear immediately (the manager is explicitly user-driven).
3138
+ if (typeof window.confirm === 'function' && !window.confirm(t('historyClearConfirm'))) return
3139
+ actions.clearHistory()
3140
+ },
3141
+ }, t('historyClear')))
3142
+ : null))
3143
+
2189
3144
  // §4.5 a non-leader copy explains why its interaction surface is gone.
2190
3145
  const familyBanner = state.familyLeader === false
2191
3146
  ? h('div', { className: 'dsa-family-notice', role: 'status' },
@@ -2241,7 +3196,9 @@ window.__ModuleLoader__.load({
2241
3196
  ['inherit', t('setSideToolsInherit')],
2242
3197
  ])),
2243
3198
  field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
2244
- field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
3199
+ field(t('setSideProvider'), providerSelect(), t('setSideProviderHint')),
3200
+ field(t('setHistoryMax'), number('historyMaxCards', 0, 50), t('setHistoryMaxHint'))),
3201
+ quickActionsEditor,
2245
3202
  h('label', { className: 'dsa-check' },
2246
3203
  h('input', {
2247
3204
  type: 'checkbox',
@@ -2249,6 +3206,29 @@ window.__ModuleLoader__.load({
2249
3206
  onChange: event => setDraft({ ...draft, showInUnclassified: event.target.checked }),
2250
3207
  }),
2251
3208
  t('setUnclassified')),
3209
+ h('label', { className: 'dsa-check' },
3210
+ h('input', {
3211
+ type: 'checkbox',
3212
+ checked: draft.contextMenu === true,
3213
+ onChange: event => setDraft({ ...draft, contextMenu: event.target.checked }),
3214
+ }),
3215
+ t('setContextMenu')),
3216
+ h('span', { className: 'dsa-muted' }, t('setContextMenuHint')),
3217
+ h('label', { className: 'dsa-check' },
3218
+ h('input', {
3219
+ type: 'checkbox',
3220
+ checked: draft.notifyOnDone === true,
3221
+ disabled: notifyPerm === 'unsupported',
3222
+ onChange: event => { void onNotifyToggle(event.target.checked) },
3223
+ }),
3224
+ t('setNotifyOnDone')),
3225
+ h('span', { className: 'dsa-muted' },
3226
+ notifyPerm === 'unsupported'
3227
+ ? t('notifyUnsupported')
3228
+ : notifyPerm === 'denied'
3229
+ ? t('notifyDenied')
3230
+ : t('setNotifyOnDoneHint')),
3231
+ historySection,
2252
3232
  h('div', { className: 'dsa-row' },
2253
3233
  h('button', {
2254
3234
  type: 'button',
@@ -2403,28 +3383,52 @@ window.__ModuleLoader__.load({
2403
3383
  // server round-trip) and replayed on boot. Everything here is optional
2404
3384
  // by design — a private-mode window (throws on setItem), a full quota,
2405
3385
  // or a tampered payload must degrade to "no history", never break boot.
2406
- const PERSIST_KEY = 'dsh-sidecard-ask:cards:v1'
2407
- const PERSIST_MAX_CARDS = 10
3386
+ // v2 (1.8.0): adds `createdAt`, a configurable cap
3387
+ // (`historyMaxCards`, 0–50) and per-entry manager actions. The v1
3388
+ // payload is read once and imported into v2 on boot; the v1 key is
3389
+ // left untouched (no destructive migration), so a downgrade still
3390
+ // finds it.
3391
+ const PERSIST_KEY = 'dsh-sidecard-ask:cards:v2'
3392
+ const PERSIST_LEGACY_KEY = 'dsh-sidecard-ask:cards:v1'
3393
+ const PERSIST_FALLBACK_CARDS = 10
2408
3394
  const PERSIST_TEXT_LIMIT = 65536
2409
3395
 
3396
+ /** Configured durable cap, clamped to the Host's 0–50 range. */
3397
+ const historyCap = () => {
3398
+ const value = Number(store.state.config.historyMaxCards)
3399
+ if (!Number.isFinite(value)) return PERSIST_FALLBACK_CARDS
3400
+ return Math.max(0, Math.min(50, Math.round(value)))
3401
+ }
3402
+
3403
+ /** Parse one localStorage envelope of a given schema version. */
3404
+ const parseEnvelope = (raw, version) => {
3405
+ if (raw === null || raw === undefined) return []
3406
+ try {
3407
+ const value = JSON.parse(raw)
3408
+ if (value?.version !== version || !Array.isArray(value.cards)) return []
3409
+ return value.cards
3410
+ .filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
3411
+ } catch { return [] }
3412
+ }
3413
+
2410
3414
  function readPersisted() {
2411
3415
  let raw = null
2412
3416
  try {
2413
3417
  raw = window.localStorage?.getItem(PERSIST_KEY)
2414
3418
  } catch { return [] }
2415
- if (raw === null || raw === undefined) return []
3419
+ const v2 = parseEnvelope(raw, 2).slice(0, 50)
3420
+ if (v2.length > 0 || (raw !== null && raw !== undefined)) return v2
3421
+ // One-time v1 import: v1 entries carry no createdAt (0 = unknown).
3422
+ let legacy = null
2416
3423
  try {
2417
- const value = JSON.parse(raw)
2418
- if (value?.version !== 1 || !Array.isArray(value.cards)) return []
2419
- return value.cards
2420
- .filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
2421
- .slice(0, PERSIST_MAX_CARDS)
3424
+ legacy = window.localStorage?.getItem(PERSIST_LEGACY_KEY)
2422
3425
  } catch { return [] }
3426
+ return parseEnvelope(legacy, 1).slice(0, 50).map(entry => ({ ...entry, createdAt: 0 }))
2423
3427
  }
2424
3428
 
2425
3429
  function writePersisted(cards) {
2426
3430
  try {
2427
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 1, cards }))
3431
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 2, cards }))
2428
3432
  } catch (error) {
2429
3433
  // Quota or privacy mode — the cards stay on screen, just not durable.
2430
3434
  console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
@@ -2433,11 +3437,12 @@ window.__ModuleLoader__.load({
2433
3437
 
2434
3438
  /** Recompute the durable set from the live store and write it. */
2435
3439
  const syncPersisted = () => {
3440
+ const cap = historyCap()
2436
3441
  const finished = store.state.cards
2437
3442
  .filter(card => card.carrier === 'side'
2438
3443
  && (card.status === 'done' || card.status === 'stopped')
2439
3444
  && card.text !== '')
2440
- .slice(0, PERSIST_MAX_CARDS)
3445
+ .slice(0, cap)
2441
3446
  .map(card => ({
2442
3447
  id: card.id,
2443
3448
  question: card.question,
@@ -2450,15 +3455,38 @@ window.__ModuleLoader__.load({
2450
3455
  zone: card.zone,
2451
3456
  truncated: card.truncated === true,
2452
3457
  droppedChars: Number(card.droppedChars ?? 0),
3458
+ createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
3459
+ provider: typeof card.provider === 'string' ? card.provider : '',
3460
+ elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
2453
3461
  history: Array.isArray(card.history) ? card.history.slice(-12) : [],
2454
3462
  }))
3463
+ // cap === 0 disables persistence: do not touch the existing key
3464
+ // (documented as "kept until an explicit clear"), just mirror the
3465
+ // in-memory state (empty manager view).
3466
+ if (cap === 0) {
3467
+ store.set({ history: [] })
3468
+ return
3469
+ }
2455
3470
  writePersisted(finished)
3471
+ store.set({ history: finished })
2456
3472
  }
2457
3473
 
2458
3474
  /** Replay finished cards from a previous page load into the store. */
2459
3475
  const restorePersisted = () => {
2460
- const saved = readPersisted()
3476
+ // Detect the v1→v2 migration BEFORE reading: when the v2 key is
3477
+ // absent but a legacy payload exists, the imported set is written
3478
+ // out under v2 once (non-destructively; v1 is kept).
3479
+ let v2Raw
3480
+ try {
3481
+ v2Raw = window.localStorage?.getItem(PERSIST_KEY)
3482
+ } catch { v2Raw = null }
3483
+ const cap = historyCap()
3484
+ const saved = readPersisted().slice(0, cap)
3485
+ store.set({ history: saved })
2461
3486
  if (saved.length === 0) return
3487
+ if (cap > 0 && (v2Raw === null || v2Raw === undefined)) {
3488
+ writePersisted(saved)
3489
+ }
2462
3490
  const replayed = saved.map(entry => ({
2463
3491
  id: entry.id,
2464
3492
  question: entry.question,
@@ -2473,6 +3501,9 @@ window.__ModuleLoader__.load({
2473
3501
  droppedChars: Number(entry.droppedChars ?? 0),
2474
3502
  streaming: null,
2475
3503
  error: null,
3504
+ provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
3505
+ elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
3506
+ createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
2476
3507
  // A native/better tab cannot survive a reload, so history always
2477
3508
  // comes back on the flow stack where it is visible immediately.
2478
3509
  surface: 'flow',
@@ -2486,6 +3517,34 @@ window.__ModuleLoader__.load({
2486
3517
  store.set({ cards: [...replayed, ...store.state.cards] })
2487
3518
  }
2488
3519
 
3520
+ /** Map one durable history entry back to a live flow card. */
3521
+ const historyEntryToCard = (entry) => ({
3522
+ id: entry.id,
3523
+ question: entry.question,
3524
+ selected: String(entry.selected ?? ''),
3525
+ selection: String(entry.selection ?? ''),
3526
+ text: String(entry.text ?? ''),
3527
+ reasoning: String(entry.reasoning ?? ''),
3528
+ status: entry.status === 'stopped' ? 'stopped' : 'done',
3529
+ carrier: 'side',
3530
+ zone: entry.zone,
3531
+ truncated: entry.truncated === true,
3532
+ droppedChars: Number(entry.droppedChars ?? 0),
3533
+ streaming: null,
3534
+ error: null,
3535
+ provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : null,
3536
+ elapsedMs: Number.isFinite(entry.elapsedMs) ? entry.elapsedMs : null,
3537
+ createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : 0,
3538
+ notified: true,
3539
+ surface: 'flow',
3540
+ open: true,
3541
+ history: Array.isArray(entry.history)
3542
+ ? entry.history
3543
+ .filter(turn => turn !== null && typeof turn === 'object')
3544
+ .map(turn => ({ question: String(turn.question ?? ''), answer: String(turn.answer ?? '') }))
3545
+ : [],
3546
+ })
3547
+
2489
3548
  const actions = {
2490
3549
  openPopover(candidate) {
2491
3550
  store.set({ popover: { candidate } })
@@ -2493,6 +3552,49 @@ window.__ModuleLoader__.load({
2493
3552
  closePopover() {
2494
3553
  store.set({ popover: null })
2495
3554
  },
3555
+ openContextMenu(candidate, position) {
3556
+ store.set({ contextMenu: { candidate, position } })
3557
+ },
3558
+ closeContextMenu() {
3559
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
3560
+ },
3561
+ /** Bring one durable card back onto the floating stack. */
3562
+ restoreHistoryCard(id) {
3563
+ const entry = store.state.history.find(item => item.id === id)
3564
+ if (entry === undefined) return false
3565
+ if (store.state.cards.some(card => card.id === id)) {
3566
+ patchCard(id, { surface: 'flow', open: true })
3567
+ } else {
3568
+ store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
3569
+ }
3570
+ // A hidden (off/capsule) layer would swallow the restored card;
3571
+ // this is a session-only override, exactly like clicking expand.
3572
+ setFloatMode('full')
3573
+ toast(t('historyRestored'))
3574
+ return true
3575
+ },
3576
+ /** Delete one entry from durable history (and its live card). */
3577
+ deleteHistoryCard(id) {
3578
+ const next = store.state.history
3579
+ .filter(item => item.id !== id)
3580
+ .slice(0, historyCap())
3581
+ writePersisted(next)
3582
+ store.set({
3583
+ history: next,
3584
+ cards: store.state.cards.filter(card => card.id !== id),
3585
+ })
3586
+ toast(t('historyDeleted'))
3587
+ },
3588
+ /** Empty the durable history without touching running cards. */
3589
+ clearHistory() {
3590
+ try {
3591
+ window.localStorage?.removeItem(PERSIST_KEY)
3592
+ } catch (error) {
3593
+ console.warn(`[${PLUGIN_ID}] history clear failed:`, error)
3594
+ }
3595
+ store.set({ history: [] })
3596
+ toast(t('historyClearDone'))
3597
+ },
2496
3598
  async submit(candidate, payload) {
2497
3599
  store.set({ popover: null, trigger: null })
2498
3600
  const config = store.state.config
@@ -2514,6 +3616,13 @@ window.__ModuleLoader__.load({
2514
3616
  surface: 'flow',
2515
3617
  open: true,
2516
3618
  history: [],
3619
+ // #9 transparency: provider from the start event, elapsed
3620
+ // measured across the stream; createdAt feeds history v2.
3621
+ provider: null,
3622
+ elapsedMs: null,
3623
+ createdAt: Date.now(),
3624
+ // #5 guard: at most one notification per card.
3625
+ notified: false,
2517
3626
  }
2518
3627
  if (payload.carrier === 'main') {
2519
3628
  store.set({ cards: [card, ...store.state.cards] })
@@ -2582,6 +3691,18 @@ window.__ModuleLoader__.load({
2582
3691
  }
2583
3692
  void runSideCard(card.id)
2584
3693
  },
3694
+ /**
3695
+ * One quick-action chip: resolve the template ({selection} is the
3696
+ * only placeholder), then take the SAME path as a popover submit
3697
+ * fixed to the side card — surface picking, render proof and the
3698
+ * stream loop all live in submit(), so this stays a thin adapter.
3699
+ */
3700
+ quickAsk(candidate, action) {
3701
+ const config = store.state.config
3702
+ const question = renderActionQuestion(action, candidate.text, config.maxChars)
3703
+ if (question.trim() === '') return
3704
+ void actions.submit(candidate, { question, carrier: 'side' })
3705
+ },
2585
3706
  copy(card, element) {
2586
3707
  void copyText(card.text, element).then((result) => {
2587
3708
  toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
@@ -2622,6 +3743,25 @@ window.__ModuleLoader__.load({
2622
3743
  patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
2623
3744
  if (card.carrier === 'side') void runSideCard(card.id)
2624
3745
  },
3746
+ /**
3747
+ * Regenerate the CURRENT turn with an edited question. Unlike
3748
+ * followUp, this does NOT push the old Q/A into `history`: the
3749
+ * earlier turns on the card stay, the question text is replaced,
3750
+ * and the answer streams again from scratch.
3751
+ */
3752
+ retryEdit(card, question) {
3753
+ const trimmed = String(question ?? '').trim()
3754
+ if (trimmed === '' || card.carrier !== 'side') return
3755
+ patchCard(card.id, {
3756
+ question: trimmed,
3757
+ status: 'pending',
3758
+ text: '',
3759
+ reasoning: '',
3760
+ error: null,
3761
+ streaming: null,
3762
+ })
3763
+ void runSideCard(card.id)
3764
+ },
2625
3765
  cancel(card) {
2626
3766
  const controller = controllers.get(card.id)
2627
3767
  if (controller !== undefined) {
@@ -2687,7 +3827,9 @@ window.__ModuleLoader__.load({
2687
3827
  } catch { /* the right rail owns its lifecycle */ }
2688
3828
  }
2689
3829
  store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
2690
- syncPersisted()
3830
+ // #6: closing a card only removes it from the live surface; the
3831
+ // durable history (written when the answer finished) is retained
3832
+ // and managed explicitly in the settings-page history manager.
2691
3833
  },
2692
3834
  async saveConfig(patch) {
2693
3835
  const value = await postJson('config', patch)
@@ -2756,7 +3898,19 @@ window.__ModuleLoader__.load({
2756
3898
  if (card === undefined) return
2757
3899
  const controller = new AbortController()
2758
3900
  controllers.set(cardId, controller)
2759
- patchCard(cardId, { status: 'streaming', text: '', reasoning: '', error: null })
3901
+ // #9: wall time from the (re)start of THIS attempt to its terminal
3902
+ // frame. retry/retryEdit both run this function again, so the timer
3903
+ // naturally measures the fresh attempt only.
3904
+ const startedAt = Date.now()
3905
+ patchCard(cardId, {
3906
+ status: 'streaming',
3907
+ text: '',
3908
+ reasoning: '',
3909
+ error: null,
3910
+ provider: null,
3911
+ elapsedMs: null,
3912
+ notified: false,
3913
+ })
2760
3914
  await streamAsk({
2761
3915
  id: cardId,
2762
3916
  question: card.question,
@@ -2774,6 +3928,7 @@ window.__ModuleLoader__.load({
2774
3928
  // Reported by the host so the card can be honest about the
2775
3929
  // read-only guard when the provider cannot take a tool filter.
2776
3930
  toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
3931
+ provider: typeof data?.provider === 'string' ? data.provider : (card.provider ?? null),
2777
3932
  })
2778
3933
  },
2779
3934
  onDelta: data => bufferStreamDelta(cardId, String(data?.text ?? '')),
@@ -2785,20 +3940,27 @@ window.__ModuleLoader__.load({
2785
3940
  if (controller.signal.aborted) return
2786
3941
  flushStreamBuffers()
2787
3942
  const current = store.state.cards.find(item => item.id === cardId)
3943
+ const status = data?.aborted === true ? 'stopped' : 'done'
2788
3944
  patchCard(cardId, {
2789
- status: data?.aborted === true ? 'stopped' : 'done',
3945
+ status,
2790
3946
  streaming: data?.streaming === true,
2791
3947
  text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
2792
3948
  reasoning: typeof data?.reasoning === 'string' ? data.reasoning : (current?.reasoning ?? ''),
3949
+ elapsedMs: Date.now() - startedAt,
2793
3950
  })
2794
3951
  controllers.delete(cardId)
2795
3952
  syncPersisted()
3953
+ // #5: background finish notification (guarded inside).
3954
+ const finished = store.state.cards.find(item => item.id === cardId)
3955
+ if (finished !== undefined) notifyCardFinish(finished)
2796
3956
  },
2797
3957
  onError: (error) => {
2798
3958
  if (controller.signal.aborted) return
2799
3959
  flushStreamBuffers()
2800
- patchCard(cardId, { status: 'error', error })
3960
+ patchCard(cardId, { status: 'error', error, elapsedMs: Date.now() - startedAt })
2801
3961
  controllers.delete(cardId)
3962
+ const failed = store.state.cards.find(item => item.id === cardId)
3963
+ if (failed !== undefined) notifyCardFinish(failed)
2802
3964
  },
2803
3965
  })
2804
3966
  }
@@ -2857,12 +4019,41 @@ window.__ModuleLoader__.load({
2857
4019
  const element = elementOf(event.target)
2858
4020
  if (element !== null && element.closest('[data-dsa-root]') !== null) return
2859
4021
  if (store.state.popover !== null) store.set({ popover: null })
4022
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
4023
+ }
4024
+
4025
+ // #8: right-click on a qualifying selection raises OUR menu instead
4026
+ // of the native one. The same gates as the trigger button apply —
4027
+ // leader, config toggle, editable-field exclusion, zone filter — and
4028
+ // when any gate fails we do NOT preventDefault, so the host's native
4029
+ // menu opens untouched (that is the documented conflict fallback).
4030
+ const onContextMenu = (event) => {
4031
+ if (store.state.familyLeader === false) return
4032
+ const config = store.state.config
4033
+ if (config.contextMenu !== true) return
4034
+ const candidate = readSelection()
4035
+ const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
4036
+ if (!decision.ok) {
4037
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
4038
+ return
4039
+ }
4040
+ event.preventDefault()
4041
+ event.stopPropagation()
4042
+ actions.openContextMenu(candidate, { x: event.clientX, y: event.clientY })
2860
4043
  }
2861
4044
 
2862
4045
  const onKeyDown = (event) => {
2863
4046
  // §4.5 only the leader answers the shortcut; otherwise two copies
2864
4047
  // would each preventDefault and each open their own popover.
2865
4048
  if (store.state.familyLeader === false) return
4049
+ // Never steal keys while the user is typing in our own popover or
4050
+ // card editors (Enter/Escape there are handled by the components).
4051
+ const target = elementOf(event.target)
4052
+ if (target !== null && target.closest('[data-dsa-root]') !== null
4053
+ && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'
4054
+ || target.closest('[contenteditable=""],[contenteditable="true"]') !== null)) {
4055
+ return
4056
+ }
2866
4057
  const config = store.state.config
2867
4058
  const facts = parseShortcut(config.shortcut)
2868
4059
  if (matchesShortcut(event, facts)) {
@@ -2873,13 +4064,27 @@ window.__ModuleLoader__.load({
2873
4064
  return
2874
4065
  }
2875
4066
  }
4067
+ // Per-action shortcuts: the main shortcut wins on a collision, and
4068
+ // the candidate falls back to the last raised trigger, exactly like
4069
+ // the main shortcut. A chip then submits straight to a side card.
4070
+ for (const action of quickActionsOf(config)) {
4071
+ if (action.shortcut === '' || !matchesShortcut(event, parseShortcut(action.shortcut))) continue
4072
+ const candidate = readSelection() ?? store.state.trigger
4073
+ if (candidate !== null) {
4074
+ event.preventDefault()
4075
+ actions.quickAsk(candidate, action)
4076
+ return
4077
+ }
4078
+ }
2876
4079
  if (event.key === 'Escape') {
2877
4080
  if (store.state.popover !== null) store.set({ popover: null })
4081
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
2878
4082
  }
2879
4083
  }
2880
4084
 
2881
4085
  const onScroll = () => {
2882
4086
  if (store.state.popover === null && store.state.trigger !== null) store.set({ trigger: null })
4087
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
2883
4088
  }
2884
4089
 
2885
4090
  if (typeof document !== 'undefined') {
@@ -2887,6 +4092,7 @@ window.__ModuleLoader__.load({
2887
4092
  document.addEventListener('pointerup', schedule, true)
2888
4093
  document.addEventListener('keyup', schedule, true)
2889
4094
  document.addEventListener('mousedown', onDocumentDown, true)
4095
+ document.addEventListener('contextmenu', onContextMenu, true)
2890
4096
  document.addEventListener('keydown', onKeyDown, true)
2891
4097
  window.addEventListener('scroll', onScroll, true)
2892
4098
  window.addEventListener('resize', onScroll, true)
@@ -2895,6 +4101,7 @@ window.__ModuleLoader__.load({
2895
4101
  document.removeEventListener('pointerup', schedule, true)
2896
4102
  document.removeEventListener('keyup', schedule, true)
2897
4103
  document.removeEventListener('mousedown', onDocumentDown, true)
4104
+ document.removeEventListener('contextmenu', onContextMenu, true)
2898
4105
  document.removeEventListener('keydown', onKeyDown, true)
2899
4106
  window.removeEventListener('scroll', onScroll, true)
2900
4107
  window.removeEventListener('resize', onScroll, true)
@@ -3288,6 +4495,7 @@ window.__ModuleLoader__.load({
3288
4495
  onCopy: element => actions.copy(card, element),
3289
4496
  onFollowUp: text => actions.followUp(card, text),
3290
4497
  onRetry: () => actions.retry(card),
4498
+ onRetryEdit: text => actions.retryEdit(card, text),
3291
4499
  onCancel: () => actions.cancel(card),
3292
4500
  onSendToMain: () => { void actions.sendToMain(card) },
3293
4501
  onClose: () => actions.close(card),
@@ -3385,7 +4593,9 @@ window.__ModuleLoader__.load({
3385
4593
  store.set({
3386
4594
  trigger: null,
3387
4595
  popover: null,
4596
+ contextMenu: null,
3388
4597
  cards: [],
4598
+ history: [],
3389
4599
  toast: null,
3390
4600
  sessionId: null,
3391
4601
  surface: 'flow',
@@ -3423,6 +4633,22 @@ window.__ModuleLoader__.load({
3423
4633
  truncateSelection,
3424
4634
  parseShortcut,
3425
4635
  matchesShortcut,
4636
+ quickActionsOf,
4637
+ renderActionQuestion,
4638
+ validateQuickActionsDraft,
4639
+ shortcutFingerprint,
4640
+ followUpChips,
4641
+ formatElapsed,
4642
+ formatHistoryTime,
4643
+ notificationSupported,
4644
+ notificationPermission,
4645
+ shouldNotifyFinish,
4646
+ buildFinishNotification,
4647
+ requestNotificationPermission,
4648
+ QUICK_ACTION_BUILTINS,
4649
+ QUICK_ACTION_LIMITS,
4650
+ FOLLOW_UP_CHIPS,
4651
+ CONTEXT_MENU_ACTION_LIMIT,
3426
4652
  classifyZone,
3427
4653
  slotPathOf,
3428
4654
  shouldOffer,