dsh-sidecard-ask 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.1
4
+
5
+ **主题:P1 体验补齐——后台通知、历史卡片管理、答后追问芯片、右键菜单、作答透明信息、键盘操作。**
6
+
7
+ - **作答完成后台通知**:新增 `notifyOnDone`(默认关)。仅当页面处于后台(其它标签页/窗口)且作答
8
+ 到达终态(完成/失败)时弹系统 Notification;设置页打开开关时即时请求浏览器通知权限,
9
+ 权限拒绝/环境不支持时就地说明并静默降级;同一张卡至多通知一次,点击通知聚焦页面。
10
+ - **历史卡片管理中心**:localStorage schema **v1 → v2**(新增 `createdAt`、provider、耗时);
11
+ 首次启动自动从 v1 非破坏式导入(v1 键保留,便于降级)。设置页新增「历史追问卡片」区:
12
+ 关键字搜索(问题/答案)、恢复到浮层、复制、单条删除、清空全部(带确认)。
13
+ 新增 `historyMaxCards`(0–50,默认 10,**0 = 不写盘**)。语义变化:**关闭浮层卡片不再删除
14
+ 持久历史**(1.7.0 及以前会随关闭清空),历史改由管理中心显式删除。
15
+ - **答后通用追问芯片**:已完成/已停止的侧边卡片答案下出现「更简短 / 更详细 / 举个例子 /
16
+ 译成英文」四个固定芯片,一键追加一轮(旧问答进入 history,区别于 1.7.0 的「改问」重新生成当前轮)。
17
+ - **选区右键菜单**:新增 `contextMenu`(默认开)。在可触发区域右键选中文字时,菜单顶部出现
18
+ 「追问选中内容」+ 前三个已启用快捷动作;任何门槛不满足(编辑框内/区域过滤/开关关闭/非 leader)
19
+ 都**不拦截**原生右键菜单,作为与宿主菜单冲突时的回退;滚动/Esc/外部点击自动关闭。
20
+ - **作答透明信息**:卡片问题下显示 provider(子代理)与本次作答耗时(`<1s`/`Ns`/`XmYs`),
21
+ 随历史持久化;开始事件的 `provider` 字段下放到卡片。
22
+ - **键盘操作**:提问框中 `Alt+1..9` 直接触发对应芯片;芯片行支持 ←/→/Home/End 焦点环移;
23
+ 卡片可聚焦(tabIndex 0),聚焦后 `R` 重试(失败/停止态)、`C` 复制答案、`Esc` 取消聚焦,
24
+ 卡片标题悬停可见按键提示;插件自身输入框内不拦截任何快捷键。
25
+ - 新增样式 `.dsa-ctxmenu/.dsa-ctx-item/.dsa-fu-chips/.dsa-hist-*`,右键菜单根节点同步
26
+ Electron `-webkit-app-region:no-drag` 守卫;中英词典新增 40+ 键(整表奇偶断言锁定)。
27
+
28
+ 本机实测:verify 53 / contract 721 / float-placement 38 / smoke 192 = **1004 项全部通过**
29
+ (较 1.7.0 的 861 项新增 143 项;含通知决策真值表/耗时格式化/v1→v2 迁移与
30
+ 历史恢复-删除-清空/右键菜单拦截与开关回退/Alt+数字与答后芯片端到端/新配置宿主校验等)。
31
+
32
+ > 未验证(如实记录):浏览器与桌面 Electron 内的视觉观感与真实点按仍需人工确认——重点是
33
+ > ①系统通知在切后台后的真实弹出与权限弹窗;②右键菜单是否与宿主自身选区菜单冲突
34
+ > (Electron 菜单加速键也未在真机验证);③历史管理区长列表观感。测试覆盖组件树、
35
+ > 事件监听与端到端数据流,替代不了真机观感。
36
+
3
37
  ## 1.7.0
4
38
 
5
39
  **主题:P0 交互提效——一键快捷动作、自定义动作模板、改问重试、代码块复制。**
package/README.md CHANGED
@@ -160,6 +160,9 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
160
160
  | `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
161
161
  | `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |
162
162
  | `quickActions` | 动作数组(≤12 条) | 5 个内置动作 | **一键快捷动作**:选中后按钮旁的动作芯片。条目形如 `{id, enabled, shortcut}`;内置 id 为 `builtin:explain/translate/summarize/polish/fix`,自定义动作(非 `builtin:` 前缀)还需 `label`(≤20 字)与 `question`(≤500 字,可用 `{selection}` 内嵌选中正文)。设置页可视化编辑,用户层整表覆盖默认值 | 立即(快捷键下次按键生效) |
163
+ | `notifyOnDone` | 布尔 | `false` | 页面在后台时,作答完成/失败是否弹系统通知;首次开启需浏览器通知权限 | 立即 |
164
+ | `contextMenu` | 布尔 | `true` | 是否在可触发选区的右键菜单顶部加入追问与快捷动作;与宿主菜单冲突时可关 | 立即 |
165
+ | `historyMaxCards` | 0–50 | `10` | 历史卡片持久化上限(localStorage v2);`0` = 不写盘(已有历史保留到显式清空) | 立即 |
163
166
 
164
167
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
165
168
 
@@ -467,6 +470,13 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
467
470
  7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。
468
471
  7b. 设置页「快捷动作」区:可停用内置动作、给动作配快捷键(如 `Alt+T`)、添加自定义动作
469
472
  (名称 + 问题指令,指令里可写 `{selection}`);配好后选中文字直接按快捷键等价于点芯片。
473
+ 7c. 答案完成后:问题下方显示「模型 spawn · 耗时 Xs」,答案下方出现
474
+ 「更简短 / 更详细 / 举个例子 / 译成英文」芯片;卡片可 Tab 聚焦,`R` 重试、`C` 复制、`Esc` 失焦。
475
+ 7d. 在选中文字上**右键**:应出现本插件菜单(追问 + 前三个快捷动作);设置页关闭「选区右键菜单」后,
476
+ 右键恢复为宿主原生菜单。
477
+ 7e. 设置页勾选「完成后桌面通知」会请求通知权限;授权后切到其它标签页/窗口,作答结束时应弹系统通知。
478
+ 7f. 设置页「历史追问卡片」:可搜索、恢复到浮层、单条删除、清空;「历史卡片保存数量」设 0 后新卡片不再写盘;
479
+ 注意:**关闭浮层卡片不会删历史**(1.8.0 起历史只在管理中心删除)。
470
480
  8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
471
481
  展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
472
482
  用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
@@ -515,8 +525,8 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
515
525
  ```
516
526
 
517
527
  四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
518
- 当前规模(1.7.0 本机实测,四项均 exit 0):verify **53** 项 + contract **589** 项 + float-placement **38** 项 +
519
- smoke **181** 项 = **861 项全部通过**。
528
+ 当前规模(1.8.0 本机实测,四项均 exit 0):verify **53** 项 + contract **721** 项 + float-placement **38** 项 +
529
+ smoke **192** 项 = **1004 项全部通过**。
520
530
 
521
531
  > 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
522
532
  > 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.7.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).
@@ -108,6 +108,9 @@ window.__ModuleLoader__.load({
108
108
  { id: 'builtin:polish', enabled: true, shortcut: '' },
109
109
  { id: 'builtin:fix', enabled: true, shortcut: '' },
110
110
  ],
111
+ notifyOnDone: false,
112
+ contextMenu: true,
113
+ historyMaxCards: 10,
111
114
  }
112
115
 
113
116
  /**
@@ -136,6 +139,58 @@ window.__ModuleLoader__.load({
136
139
  'builtin:fix': { labelKey: 'qaFix', questionKey: 'qaFixQ' },
137
140
  }
138
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())}`
192
+ }
193
+
139
194
  /** Zone classification: slot-key patterns the harness itself renders. */
140
195
  const ZONE_PATTERNS = {
141
196
  chat: [/^conversation\b/],
@@ -365,6 +420,40 @@ window.__ModuleLoader__.load({
365
420
  qaBadQuestion: '问题指令必填,且不超过 {n} 个字符',
366
421
  qaBadShortcut: '“{label}”的快捷键格式不正确,请用 Alt+Q 这样的写法',
367
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 取消聚焦',
368
457
  familyLegacyDiag: '旧版共载',
369
458
  familyPeers: '同页实例',
370
459
  },
@@ -538,6 +627,40 @@ window.__ModuleLoader__.load({
538
627
  qaBadQuestion: 'Questions are required and at most {n} characters',
539
628
  qaBadShortcut: 'The shortcut for "{label}" is invalid; use a form like Alt+Q',
540
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',
541
664
  },
542
665
  }
543
666
 
@@ -761,7 +884,14 @@ window.__ModuleLoader__.load({
761
884
  sessionId: null,
762
885
  trigger: null,
763
886
  popover: null,
887
+ /** The custom selection context menu: {candidate, x, y} or null. */
888
+ contextMenu: null,
764
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: [],
765
895
  toast: null,
766
896
  /** Whether the harness renders the `data-slot` zone anchors (null = unknown yet). */
767
897
  zoneAnchors: null,
@@ -1195,6 +1325,84 @@ window.__ModuleLoader__.load({
1195
1325
  return `${facts.ctrl ? 1 : 0}|${facts.alt ? 1 : 0}|${facts.shift ? 1 : 0}|${facts.meta ? 1 : 0}|${facts.key}`
1196
1326
  }
1197
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
+
1198
1406
  /**
1199
1407
  * Validate a settings-page quick-action draft BEFORE posting it, so the
1200
1408
  * Host's all-or-nothing refusal never comes back as a surprise. Also
@@ -1591,7 +1799,7 @@ window.__ModuleLoader__.load({
1591
1799
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1592
1800
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1593
1801
  Every surface we draw declares it. The property is inert in a browser. */
1594
- .dsa-layer,.dsa-trigger,.dsa-quickbar,.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}
1595
1803
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1596
1804
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1597
1805
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -1616,6 +1824,21 @@ window.__ModuleLoader__.load({
1616
1824
  /* Chips rendered INSIDE the popover need no floating shadow. */
1617
1825
  .dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
1618
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)}
1619
1842
  /* Inside the trigger bar the main button and chips are one flex row: the
1620
1843
  trigger drops its own fixed positioning, chips join the row directly. */
1621
1844
  .dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
@@ -1735,6 +1958,14 @@ window.__ModuleLoader__.load({
1735
1958
  .dsa-qa-question{flex-basis:100%!important;min-width:0!important}
1736
1959
  .dsa-qa-head{display:flex;align-items:center;gap:8px;font-size:12px;
1737
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)}
1738
1969
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
1739
1970
  color:var(--dsw-alias-label-secondary, #555)}
1740
1971
  /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
@@ -2018,10 +2249,28 @@ window.__ModuleLoader__.load({
2018
2249
  * popover. The trigger bar lays chips out directly (`display:contents`);
2019
2250
  * the popover wraps them in its own borderless chip row.
2020
2251
  */
2021
- function QuickChips({ actions, onPick, inline = false }) {
2252
+ function QuickChips({ actions, onPick, inline = false, wrapClassName }) {
2022
2253
  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', {
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', {
2025
2274
  key: action.id,
2026
2275
  type: 'button',
2027
2276
  className: 'dsa-chip',
@@ -2030,6 +2279,7 @@ window.__ModuleLoader__.load({
2030
2279
  : action.question,
2031
2280
  'aria-label': `${action.label}:${action.question}`,
2032
2281
  onMouseDown: (event) => { event.preventDefault() },
2282
+ onKeyDown: event => onChipKeyDown(event, index),
2033
2283
  onClick: (event) => {
2034
2284
  event.preventDefault()
2035
2285
  event.stopPropagation()
@@ -2062,6 +2312,64 @@ window.__ModuleLoader__.load({
2062
2312
  h(QuickChips, { actions, onPick: onQuick, inline: true }))
2063
2313
  }
2064
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
+
2065
2373
  /** The question popover. */
2066
2374
  function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
2067
2375
  const [question, setQuestion] = React.useState('')
@@ -2100,6 +2408,19 @@ window.__ModuleLoader__.load({
2100
2408
  onClose()
2101
2409
  return
2102
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
+ }
2103
2424
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
2104
2425
  event.preventDefault()
2105
2426
  void submit()
@@ -2260,7 +2581,46 @@ window.__ModuleLoader__.load({
2260
2581
  'aria-label': t(labelKey),
2261
2582
  onClick: () => onFloatMode(mode),
2262
2583
  }, glyph)
2263
- 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
+ },
2264
2624
  h('div', { className: 'dsa-row' },
2265
2625
  h('span', { className: 'dsa-title' }, t('answerTitle')),
2266
2626
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
@@ -2272,6 +2632,7 @@ window.__ModuleLoader__.load({
2272
2632
  floatControl('off', 'floatHide', '×'))
2273
2633
  : null),
2274
2634
  h('pre', { className: 'dsa-quote' }, card.question),
2635
+ metaBits.length > 0 ? h('div', { className: 'dsa-muted' }, metaBits.join(' · ')) : null,
2275
2636
  h(AnswerBody, { card, containerRef: answerRef }),
2276
2637
  card.toolFilter === 'unsupported'
2277
2638
  ? h('div', { className: 'dsa-muted' }, t('toolGuardUnavailable'))
@@ -2341,6 +2702,16 @@ window.__ModuleLoader__.load({
2341
2702
  },
2342
2703
  }, t('retryAskSubmit'))))
2343
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,
2344
2715
  h(CardActions, {
2345
2716
  card,
2346
2717
  onCopy: () => onCopy(answerRef.current),
@@ -2526,6 +2897,16 @@ window.__ModuleLoader__.load({
2526
2897
  onQuick: action => actions.quickAsk(popover.candidate, action),
2527
2898
  })
2528
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),
2908
+ })
2909
+ : null,
2529
2910
  h(FloatingLayer, { cards: state.cards, actions }),
2530
2911
  state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
2531
2912
  }
@@ -2536,10 +2917,26 @@ window.__ModuleLoader__.load({
2536
2917
  const [draft, setDraft] = React.useState(state.config)
2537
2918
  const [status, setStatus] = React.useState('idle')
2538
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')
2539
2923
  const config = state.config
2540
2924
 
2541
2925
  React.useEffect(() => { setDraft(config) }, [config])
2542
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
+
2543
2940
  const field = (label, control, hint) => h('div', { className: 'dsa-field' },
2544
2941
  h('label', null, label),
2545
2942
  control,
@@ -2677,6 +3074,73 @@ window.__ModuleLoader__.load({
2677
3074
  }
2678
3075
  }
2679
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
+
2680
3144
  // §4.5 a non-leader copy explains why its interaction surface is gone.
2681
3145
  const familyBanner = state.familyLeader === false
2682
3146
  ? h('div', { className: 'dsa-family-notice', role: 'status' },
@@ -2732,7 +3196,8 @@ window.__ModuleLoader__.load({
2732
3196
  ['inherit', t('setSideToolsInherit')],
2733
3197
  ])),
2734
3198
  field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
2735
- field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
3199
+ field(t('setSideProvider'), providerSelect(), t('setSideProviderHint')),
3200
+ field(t('setHistoryMax'), number('historyMaxCards', 0, 50), t('setHistoryMaxHint'))),
2736
3201
  quickActionsEditor,
2737
3202
  h('label', { className: 'dsa-check' },
2738
3203
  h('input', {
@@ -2741,6 +3206,29 @@ window.__ModuleLoader__.load({
2741
3206
  onChange: event => setDraft({ ...draft, showInUnclassified: event.target.checked }),
2742
3207
  }),
2743
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,
2744
3232
  h('div', { className: 'dsa-row' },
2745
3233
  h('button', {
2746
3234
  type: 'button',
@@ -2895,28 +3383,52 @@ window.__ModuleLoader__.load({
2895
3383
  // server round-trip) and replayed on boot. Everything here is optional
2896
3384
  // by design — a private-mode window (throws on setItem), a full quota,
2897
3385
  // or a tampered payload must degrade to "no history", never break boot.
2898
- const PERSIST_KEY = 'dsh-sidecard-ask:cards:v1'
2899
- 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
2900
3394
  const PERSIST_TEXT_LIMIT = 65536
2901
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
+
2902
3414
  function readPersisted() {
2903
3415
  let raw = null
2904
3416
  try {
2905
3417
  raw = window.localStorage?.getItem(PERSIST_KEY)
2906
3418
  } catch { return [] }
2907
- 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
2908
3423
  try {
2909
- const value = JSON.parse(raw)
2910
- if (value?.version !== 1 || !Array.isArray(value.cards)) return []
2911
- return value.cards
2912
- .filter(entry => typeof entry?.id === 'string' && entry.id !== '' && typeof entry.question === 'string')
2913
- .slice(0, PERSIST_MAX_CARDS)
3424
+ legacy = window.localStorage?.getItem(PERSIST_LEGACY_KEY)
2914
3425
  } catch { return [] }
3426
+ return parseEnvelope(legacy, 1).slice(0, 50).map(entry => ({ ...entry, createdAt: 0 }))
2915
3427
  }
2916
3428
 
2917
3429
  function writePersisted(cards) {
2918
3430
  try {
2919
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 1, cards }))
3431
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 2, cards }))
2920
3432
  } catch (error) {
2921
3433
  // Quota or privacy mode — the cards stay on screen, just not durable.
2922
3434
  console.warn(`[${PLUGIN_ID}] card history not persisted:`, error)
@@ -2925,11 +3437,12 @@ window.__ModuleLoader__.load({
2925
3437
 
2926
3438
  /** Recompute the durable set from the live store and write it. */
2927
3439
  const syncPersisted = () => {
3440
+ const cap = historyCap()
2928
3441
  const finished = store.state.cards
2929
3442
  .filter(card => card.carrier === 'side'
2930
3443
  && (card.status === 'done' || card.status === 'stopped')
2931
3444
  && card.text !== '')
2932
- .slice(0, PERSIST_MAX_CARDS)
3445
+ .slice(0, cap)
2933
3446
  .map(card => ({
2934
3447
  id: card.id,
2935
3448
  question: card.question,
@@ -2942,15 +3455,38 @@ window.__ModuleLoader__.load({
2942
3455
  zone: card.zone,
2943
3456
  truncated: card.truncated === true,
2944
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,
2945
3461
  history: Array.isArray(card.history) ? card.history.slice(-12) : [],
2946
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
+ }
2947
3470
  writePersisted(finished)
3471
+ store.set({ history: finished })
2948
3472
  }
2949
3473
 
2950
3474
  /** Replay finished cards from a previous page load into the store. */
2951
3475
  const restorePersisted = () => {
2952
- 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 })
2953
3486
  if (saved.length === 0) return
3487
+ if (cap > 0 && (v2Raw === null || v2Raw === undefined)) {
3488
+ writePersisted(saved)
3489
+ }
2954
3490
  const replayed = saved.map(entry => ({
2955
3491
  id: entry.id,
2956
3492
  question: entry.question,
@@ -2965,6 +3501,9 @@ window.__ModuleLoader__.load({
2965
3501
  droppedChars: Number(entry.droppedChars ?? 0),
2966
3502
  streaming: null,
2967
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,
2968
3507
  // A native/better tab cannot survive a reload, so history always
2969
3508
  // comes back on the flow stack where it is visible immediately.
2970
3509
  surface: 'flow',
@@ -2978,6 +3517,34 @@ window.__ModuleLoader__.load({
2978
3517
  store.set({ cards: [...replayed, ...store.state.cards] })
2979
3518
  }
2980
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
+
2981
3548
  const actions = {
2982
3549
  openPopover(candidate) {
2983
3550
  store.set({ popover: { candidate } })
@@ -2985,6 +3552,49 @@ window.__ModuleLoader__.load({
2985
3552
  closePopover() {
2986
3553
  store.set({ popover: null })
2987
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
+ },
2988
3598
  async submit(candidate, payload) {
2989
3599
  store.set({ popover: null, trigger: null })
2990
3600
  const config = store.state.config
@@ -3006,6 +3616,13 @@ window.__ModuleLoader__.load({
3006
3616
  surface: 'flow',
3007
3617
  open: true,
3008
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,
3009
3626
  }
3010
3627
  if (payload.carrier === 'main') {
3011
3628
  store.set({ cards: [card, ...store.state.cards] })
@@ -3210,7 +3827,9 @@ window.__ModuleLoader__.load({
3210
3827
  } catch { /* the right rail owns its lifecycle */ }
3211
3828
  }
3212
3829
  store.set({ cards: store.state.cards.filter(item => item.id !== card.id) })
3213
- 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.
3214
3833
  },
3215
3834
  async saveConfig(patch) {
3216
3835
  const value = await postJson('config', patch)
@@ -3279,7 +3898,19 @@ window.__ModuleLoader__.load({
3279
3898
  if (card === undefined) return
3280
3899
  const controller = new AbortController()
3281
3900
  controllers.set(cardId, controller)
3282
- 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
+ })
3283
3914
  await streamAsk({
3284
3915
  id: cardId,
3285
3916
  question: card.question,
@@ -3297,6 +3928,7 @@ window.__ModuleLoader__.load({
3297
3928
  // Reported by the host so the card can be honest about the
3298
3929
  // read-only guard when the provider cannot take a tool filter.
3299
3930
  toolFilter: typeof data?.toolFilter === 'string' ? data.toolFilter : null,
3931
+ provider: typeof data?.provider === 'string' ? data.provider : (card.provider ?? null),
3300
3932
  })
3301
3933
  },
3302
3934
  onDelta: data => bufferStreamDelta(cardId, String(data?.text ?? '')),
@@ -3308,20 +3940,27 @@ window.__ModuleLoader__.load({
3308
3940
  if (controller.signal.aborted) return
3309
3941
  flushStreamBuffers()
3310
3942
  const current = store.state.cards.find(item => item.id === cardId)
3943
+ const status = data?.aborted === true ? 'stopped' : 'done'
3311
3944
  patchCard(cardId, {
3312
- status: data?.aborted === true ? 'stopped' : 'done',
3945
+ status,
3313
3946
  streaming: data?.streaming === true,
3314
3947
  text: typeof data?.text === 'string' && data.text !== '' ? data.text : (current?.text ?? ''),
3315
3948
  reasoning: typeof data?.reasoning === 'string' ? data.reasoning : (current?.reasoning ?? ''),
3949
+ elapsedMs: Date.now() - startedAt,
3316
3950
  })
3317
3951
  controllers.delete(cardId)
3318
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)
3319
3956
  },
3320
3957
  onError: (error) => {
3321
3958
  if (controller.signal.aborted) return
3322
3959
  flushStreamBuffers()
3323
- patchCard(cardId, { status: 'error', error })
3960
+ patchCard(cardId, { status: 'error', error, elapsedMs: Date.now() - startedAt })
3324
3961
  controllers.delete(cardId)
3962
+ const failed = store.state.cards.find(item => item.id === cardId)
3963
+ if (failed !== undefined) notifyCardFinish(failed)
3325
3964
  },
3326
3965
  })
3327
3966
  }
@@ -3380,6 +4019,27 @@ window.__ModuleLoader__.load({
3380
4019
  const element = elementOf(event.target)
3381
4020
  if (element !== null && element.closest('[data-dsa-root]') !== null) return
3382
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 })
3383
4043
  }
3384
4044
 
3385
4045
  const onKeyDown = (event) => {
@@ -3418,11 +4078,13 @@ window.__ModuleLoader__.load({
3418
4078
  }
3419
4079
  if (event.key === 'Escape') {
3420
4080
  if (store.state.popover !== null) store.set({ popover: null })
4081
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
3421
4082
  }
3422
4083
  }
3423
4084
 
3424
4085
  const onScroll = () => {
3425
4086
  if (store.state.popover === null && store.state.trigger !== null) store.set({ trigger: null })
4087
+ if (store.state.contextMenu !== null) store.set({ contextMenu: null })
3426
4088
  }
3427
4089
 
3428
4090
  if (typeof document !== 'undefined') {
@@ -3430,6 +4092,7 @@ window.__ModuleLoader__.load({
3430
4092
  document.addEventListener('pointerup', schedule, true)
3431
4093
  document.addEventListener('keyup', schedule, true)
3432
4094
  document.addEventListener('mousedown', onDocumentDown, true)
4095
+ document.addEventListener('contextmenu', onContextMenu, true)
3433
4096
  document.addEventListener('keydown', onKeyDown, true)
3434
4097
  window.addEventListener('scroll', onScroll, true)
3435
4098
  window.addEventListener('resize', onScroll, true)
@@ -3438,6 +4101,7 @@ window.__ModuleLoader__.load({
3438
4101
  document.removeEventListener('pointerup', schedule, true)
3439
4102
  document.removeEventListener('keyup', schedule, true)
3440
4103
  document.removeEventListener('mousedown', onDocumentDown, true)
4104
+ document.removeEventListener('contextmenu', onContextMenu, true)
3441
4105
  document.removeEventListener('keydown', onKeyDown, true)
3442
4106
  window.removeEventListener('scroll', onScroll, true)
3443
4107
  window.removeEventListener('resize', onScroll, true)
@@ -3929,7 +4593,9 @@ window.__ModuleLoader__.load({
3929
4593
  store.set({
3930
4594
  trigger: null,
3931
4595
  popover: null,
4596
+ contextMenu: null,
3932
4597
  cards: [],
4598
+ history: [],
3933
4599
  toast: null,
3934
4600
  sessionId: null,
3935
4601
  surface: 'flow',
@@ -3971,8 +4637,18 @@ window.__ModuleLoader__.load({
3971
4637
  renderActionQuestion,
3972
4638
  validateQuickActionsDraft,
3973
4639
  shortcutFingerprint,
4640
+ followUpChips,
4641
+ formatElapsed,
4642
+ formatHistoryTime,
4643
+ notificationSupported,
4644
+ notificationPermission,
4645
+ shouldNotifyFinish,
4646
+ buildFinishNotification,
4647
+ requestNotificationPermission,
3974
4648
  QUICK_ACTION_BUILTINS,
3975
4649
  QUICK_ACTION_LIMITS,
4650
+ FOLLOW_UP_CHIPS,
4651
+ CONTEXT_MENU_ACTION_LIMIT,
3976
4652
  classifyZone,
3977
4653
  slotPathOf,
3978
4654
  shouldOffer,
package/index.js CHANGED
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
42
42
  export const inject = ['webServer']
43
43
 
44
44
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.7.0'
45
+ export const PLUGIN_VERSION = '1.7.1'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -100,6 +100,25 @@ export const DEFAULT_CONFIG = {
100
100
  { id: 'builtin:polish', enabled: true, shortcut: '' },
101
101
  { id: 'builtin:fix', enabled: true, shortcut: '' },
102
102
  ],
103
+ /**
104
+ * Fire a desktop Notification when a side answer finishes while the page
105
+ * is hidden (another tab/window). Off by default: enabling it also requires
106
+ * the browser's Notification permission, requested lazily on first use.
107
+ */
108
+ notifyOnDone: false,
109
+ /**
110
+ * Offer the plugin's own context menu on a qualifying selection (main
111
+ * "ask" entry plus the first quick actions). The page's native menu is
112
+ * suppressed only inside that menu; the feature can be turned off when a
113
+ * host composition ships a conflicting selection menu.
114
+ */
115
+ contextMenu: true,
116
+ /**
117
+ * How many finished cards the Client mirrors into localStorage. `0`
118
+ * disables persistence entirely (no write, existing history is left on
119
+ * disk until the next explicit clear).
120
+ */
121
+ historyMaxCards: 10,
103
122
  }
104
123
 
105
124
  /** Bounds for the `quickActions` user layer (defense in depth). */
@@ -135,6 +154,7 @@ const NUMBERS = {
135
154
  maxConcurrentAsks: [1, 12],
136
155
  sideTimeoutMs: [5_000, 3_600_000],
137
156
  floatMaxWidth: [240, 560],
157
+ historyMaxCards: [0, 50],
138
158
  }
139
159
 
140
160
  /**
@@ -301,9 +321,10 @@ export function sanitizeLayer(raw, problems = []) {
301
321
  if (typeof raw.sideProvider === 'string' && raw.sideProvider.length <= 80) out.sideProvider = raw.sideProvider
302
322
  else problems.push('sideProvider: 必须是字符串,已忽略')
303
323
  }
304
- if (raw.showInUnclassified !== undefined) {
305
- if (typeof raw.showInUnclassified === 'boolean') out.showInUnclassified = raw.showInUnclassified
306
- else problems.push('showInUnclassified: 必须是布尔值,已忽略')
324
+ for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu']) {
325
+ if (raw[boolKey] === undefined) continue
326
+ if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
327
+ else problems.push(`${boolKey}: 必须是布尔值,已忽略`)
307
328
  }
308
329
  if (raw.quickActions !== undefined) {
309
330
  const list = sanitizeQuickActions(raw.quickActions, problems)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",