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/CHANGELOG.md +69 -0
- package/README.md +22 -7
- package/client.js +1257 -31
- package/index.js +134 -4
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|