dsh-sidecard-ask 1.8.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/README.md +125 -15
- package/client.js +649 -46
- package/index.js +718 -13
- 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 = '
|
|
50
|
+
const CLIENT_VERSION = '2.0.0'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -120,6 +120,17 @@ window.__ModuleLoader__.load({
|
|
|
120
120
|
customPersona: '',
|
|
121
121
|
// 1.8.0 P2-4: model-written follow-up chips (opt-in).
|
|
122
122
|
followUpSuggestions: false,
|
|
123
|
+
// Empty by design: a maintainer mailbox is injected at run time (the
|
|
124
|
+
// settings field or DSH_SIDECARD_FEEDBACK_TO), never shipped in a repo.
|
|
125
|
+
feedbackAddress: '',
|
|
126
|
+
// SMTP transport, editable in the settings page. The password is
|
|
127
|
+
// write-only: `/state` never returns it, so this default is always ''.
|
|
128
|
+
feedbackSmtpHost: '',
|
|
129
|
+
feedbackSmtpPort: 465,
|
|
130
|
+
feedbackSmtpSecure: true,
|
|
131
|
+
feedbackSmtpUser: '',
|
|
132
|
+
feedbackSmtpPass: '',
|
|
133
|
+
feedbackSmtpFrom: '',
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
/**
|
|
@@ -134,6 +145,26 @@ window.__ModuleLoader__.load({
|
|
|
134
145
|
shortcutLength: 40,
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Fallback bounds for the feedback form. `/state` reports the live limits
|
|
150
|
+
* (`capabilities.feedback.limits`) and those win; these exist only so an
|
|
151
|
+
* older Host that reports nothing still gets sane Client-side checks.
|
|
152
|
+
*/
|
|
153
|
+
const FEEDBACK_DEFAULTS = {
|
|
154
|
+
titleMin: 4,
|
|
155
|
+
titleMax: 120,
|
|
156
|
+
bodyMin: 10,
|
|
157
|
+
bodyMax: 4000,
|
|
158
|
+
contactMax: 120,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The Host's mailbox rule, mirrored so the form refuses a bad `Reply-To`
|
|
163
|
+
* before the round trip. Kept deliberately simple: the relay is the final
|
|
164
|
+
* authority, and this only has to reject obvious non-addresses.
|
|
165
|
+
*/
|
|
166
|
+
const FEEDBACK_EMAIL_PATTERN = /^[^\s@<>()[\]\\,;:"/]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/
|
|
167
|
+
|
|
137
168
|
/**
|
|
138
169
|
* Built-in quick actions. The config list only carries `builtin:<id>`;
|
|
139
170
|
* the localized label and question live HERE so the wire/config layers
|
|
@@ -445,6 +476,12 @@ window.__ModuleLoader__.load({
|
|
|
445
476
|
answerEmpty: '(没有内容)',
|
|
446
477
|
cardClosed: '该卡片已关闭',
|
|
447
478
|
cardMovedToFlow: '该卡片已移至浮层显示',
|
|
479
|
+
cardDelete: '删除这条追问对话',
|
|
480
|
+
cardDeleteTitle: '删除这条追问对话?',
|
|
481
|
+
cardDeleteBody: '会同时从浮层和追问历史里移除,且不可撤销。',
|
|
482
|
+
cardDeleteRounds: '含 {n} 轮追问',
|
|
483
|
+
cardDeleteConfirm: '删除',
|
|
484
|
+
cardDeleted: '已删除这条追问对话',
|
|
448
485
|
reasoningTitle: '思考过程',
|
|
449
486
|
copy: '复制',
|
|
450
487
|
copied: '已复制',
|
|
@@ -465,6 +502,7 @@ window.__ModuleLoader__.load({
|
|
|
465
502
|
errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
|
|
466
503
|
errorTooLarge: '请求体过大',
|
|
467
504
|
errorBadRequest: '请求无效(问题或选中文本为空)',
|
|
505
|
+
errorFeedbackInvalid: '反馈内容不符合要求,检查标题与详细描述的长度',
|
|
468
506
|
errorUnloaded: '插件正在卸载,无法受理新的追问',
|
|
469
507
|
errorDuplicate: '该追问已在处理中,请等它完成',
|
|
470
508
|
errorBusy: '并发追问已达上限({n}),请稍后再试',
|
|
@@ -496,7 +534,6 @@ window.__ModuleLoader__.load({
|
|
|
496
534
|
setFloatMode: '浮层默认形态',
|
|
497
535
|
setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
|
|
498
536
|
setFloatModeFull: '直接展开成卡片',
|
|
499
|
-
setFloatModeOff: '关闭浮层(只留小圆点)',
|
|
500
537
|
setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
|
|
501
538
|
setFloatMaxWidth: '浮层宽度上限(px)',
|
|
502
539
|
setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
|
|
@@ -505,9 +542,6 @@ window.__ModuleLoader__.load({
|
|
|
505
542
|
floatDocked: '已占满右侧栏',
|
|
506
543
|
floatExpand: '展开侧边卡片',
|
|
507
544
|
floatCollapse: '收起为胶囊(不遮挡对话)',
|
|
508
|
-
floatHide: '隐藏浮层(只留小圆点)',
|
|
509
|
-
floatRestore: '恢复浮层',
|
|
510
|
-
floatCount: '{n} 条追问回答',
|
|
511
545
|
floatStreaming: '正在作答…',
|
|
512
546
|
floatDone: '已完成 {n} 条',
|
|
513
547
|
setShortcut: '快捷键',
|
|
@@ -656,6 +690,38 @@ window.__ModuleLoader__.load({
|
|
|
656
690
|
suggestionsLabel: '建议追问',
|
|
657
691
|
familyLegacyDiag: '旧版共载',
|
|
658
692
|
familyPeers: '同页实例',
|
|
693
|
+
setFeedback: '反馈',
|
|
694
|
+
setFeedbackHint: '遇到问题或有建议,在这里直接发邮件给我;邮件会自动附带插件版本与运行环境(不含聊天内容)',
|
|
695
|
+
feedbackOpen: '反馈问题',
|
|
696
|
+
feedbackClose: '关闭',
|
|
697
|
+
feedbackDialogTitle: '发送反馈',
|
|
698
|
+
feedbackKind: '问题类型',
|
|
699
|
+
feedbackKindBug: '缺陷',
|
|
700
|
+
feedbackKindIdea: '建议',
|
|
701
|
+
feedbackKindQuestion: '疑问',
|
|
702
|
+
feedbackKindOther: '其他',
|
|
703
|
+
feedbackTitle: '标题',
|
|
704
|
+
feedbackTitlePlaceholder: '一句话说明问题(4–120 字)',
|
|
705
|
+
feedbackBody: '详细描述',
|
|
706
|
+
feedbackBodyPlaceholder: '复现步骤 / 期望结果 / 实际结果(10–4000 字)',
|
|
707
|
+
feedbackContact: '你的邮箱(选填,作为回信地址)',
|
|
708
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
709
|
+
feedbackContactHint: '填了维护者就能直接回信给你;留空即匿名。',
|
|
710
|
+
feedbackContactInvalid: '请填写正确的邮箱地址,或留空',
|
|
711
|
+
feedbackSend: '发送邮件',
|
|
712
|
+
feedbackSending: '发送中…',
|
|
713
|
+
feedbackSent: '已发送,谢谢反馈',
|
|
714
|
+
feedbackCopy: '复制内容',
|
|
715
|
+
feedbackCopied: '已复制到剪贴板',
|
|
716
|
+
feedbackUnconfigured: '没有可用的发信服务,暂时不能直接寄出;可用“复制内容”自己发给我',
|
|
717
|
+
feedbackNoCredentials: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);可用“复制内容”自己发给我',
|
|
718
|
+
feedbackNeedsTitle: '标题至少 4 个字',
|
|
719
|
+
feedbackNeedsBody: '详细描述至少 10 个字',
|
|
720
|
+
feedbackContactTooLong: '联系方式太长(≤{n} 字)',
|
|
721
|
+
feedbackFailed: '发送失败:{message}',
|
|
722
|
+
feedbackEnvNote: '自动附带:插件版本、Node/平台、发送时间',
|
|
723
|
+
feedbackTooFast: '刚发过一封,请稍等半分钟',
|
|
724
|
+
feedbackTooMany: '发送过于频繁,请稍后再试',
|
|
659
725
|
},
|
|
660
726
|
en: {
|
|
661
727
|
triggerLabel: 'Ask about selection',
|
|
@@ -686,6 +752,12 @@ window.__ModuleLoader__.load({
|
|
|
686
752
|
answerEmpty: '(empty)',
|
|
687
753
|
cardClosed: 'This card was closed',
|
|
688
754
|
cardMovedToFlow: 'This card moved to the floating stack',
|
|
755
|
+
cardDelete: 'Delete this follow-up conversation',
|
|
756
|
+
cardDeleteTitle: 'Delete this follow-up conversation?',
|
|
757
|
+
cardDeleteBody: 'It will be removed from both the floating layer and the follow-up history. This cannot be undone.',
|
|
758
|
+
cardDeleteRounds: 'Includes {n} follow-up turn(s)',
|
|
759
|
+
cardDeleteConfirm: 'Delete',
|
|
760
|
+
cardDeleted: 'Deleted this follow-up conversation',
|
|
689
761
|
reasoningTitle: 'Reasoning',
|
|
690
762
|
copy: 'Copy',
|
|
691
763
|
copied: 'Copied',
|
|
@@ -706,6 +778,7 @@ window.__ModuleLoader__.load({
|
|
|
706
778
|
errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
|
|
707
779
|
errorTooLarge: 'The request body is too large',
|
|
708
780
|
errorBadRequest: 'Invalid request (empty question or empty selection)',
|
|
781
|
+
errorFeedbackInvalid: 'The report does not meet the length limits — check the title and details',
|
|
709
782
|
errorUnloaded: 'The plugin is unloading and cannot take new follow-ups',
|
|
710
783
|
errorDuplicate: 'This follow-up is already being answered; wait for it to finish',
|
|
711
784
|
errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
|
|
@@ -737,7 +810,6 @@ window.__ModuleLoader__.load({
|
|
|
737
810
|
setFloatMode: 'Floating layer default',
|
|
738
811
|
setFloatModeCapsule: 'Capsule (never covers the conversation)',
|
|
739
812
|
setFloatModeFull: 'Expanded card stack',
|
|
740
|
-
setFloatModeOff: 'Hidden (small launcher only)',
|
|
741
813
|
setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
|
|
742
814
|
setFloatMaxWidth: 'Floating layer max width (px)',
|
|
743
815
|
setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
|
|
@@ -746,9 +818,6 @@ window.__ModuleLoader__.load({
|
|
|
746
818
|
floatDocked: 'Filled the right rail',
|
|
747
819
|
floatExpand: 'Expand the side cards',
|
|
748
820
|
floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
|
|
749
|
-
floatHide: 'Hide the layer (leaves a small launcher)',
|
|
750
|
-
floatRestore: 'Restore the floating layer',
|
|
751
|
-
floatCount: '{n} follow-up answers',
|
|
752
821
|
floatStreaming: 'Answering…',
|
|
753
822
|
floatDone: '{n} done',
|
|
754
823
|
setShortcut: 'Shortcut',
|
|
@@ -897,6 +966,38 @@ window.__ModuleLoader__.load({
|
|
|
897
966
|
suggestionsLabel: 'Suggested follow-ups',
|
|
898
967
|
familyLegacyDiag: 'Legacy co-load',
|
|
899
968
|
familyPeers: 'Copies on page',
|
|
969
|
+
setFeedback: 'Feedback',
|
|
970
|
+
setFeedbackHint: 'Report a bug or send a suggestion by mail from here; the plugin version and runtime details are attached automatically (no chat content)',
|
|
971
|
+
feedbackOpen: 'Send feedback',
|
|
972
|
+
feedbackClose: 'Close',
|
|
973
|
+
feedbackDialogTitle: 'Send feedback',
|
|
974
|
+
feedbackKind: 'Kind',
|
|
975
|
+
feedbackKindBug: 'Bug',
|
|
976
|
+
feedbackKindIdea: 'Idea',
|
|
977
|
+
feedbackKindQuestion: 'Question',
|
|
978
|
+
feedbackKindOther: 'Other',
|
|
979
|
+
feedbackTitle: 'Title',
|
|
980
|
+
feedbackTitlePlaceholder: 'One line (4–120 characters)',
|
|
981
|
+
feedbackBody: 'Details',
|
|
982
|
+
feedbackBodyPlaceholder: 'Steps to reproduce / expected / actual (10–4000 characters)',
|
|
983
|
+
feedbackContact: 'Your email (optional, used as the reply address)',
|
|
984
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
985
|
+
feedbackContactHint: 'Filled in, a reply reaches you directly; leave it empty to stay anonymous.',
|
|
986
|
+
feedbackContactInvalid: 'Enter a valid email address, or leave it empty',
|
|
987
|
+
feedbackSend: 'Send mail',
|
|
988
|
+
feedbackSending: 'Sending…',
|
|
989
|
+
feedbackSent: 'Sent — thank you',
|
|
990
|
+
feedbackCopy: 'Copy text',
|
|
991
|
+
feedbackCopied: 'Copied to the clipboard',
|
|
992
|
+
feedbackUnconfigured: 'No mail service is configured, so the report cannot be sent from here; use “Copy text” and send it yourself',
|
|
993
|
+
feedbackNoCredentials: 'The recipient is set; only the SMTP credentials (environment variables or the user config) are missing, so use “Copy text” and send it yourself',
|
|
994
|
+
feedbackNeedsTitle: 'The title needs at least 4 characters',
|
|
995
|
+
feedbackNeedsBody: 'The details need at least 10 characters',
|
|
996
|
+
feedbackContactTooLong: 'The contact line is too long (max {n} characters)',
|
|
997
|
+
feedbackFailed: 'Send failed: {message}',
|
|
998
|
+
feedbackEnvNote: 'Attached automatically: plugin version, Node/platform, send time',
|
|
999
|
+
feedbackTooFast: 'One mail just went out — wait half a minute',
|
|
1000
|
+
feedbackTooMany: 'Too many reports for now — try again later',
|
|
900
1001
|
},
|
|
901
1002
|
}
|
|
902
1003
|
|
|
@@ -967,6 +1068,12 @@ window.__ModuleLoader__.load({
|
|
|
967
1068
|
'bad-response': { key: 'errorBadResponseBody' },
|
|
968
1069
|
'stream-broken': { key: 'errorStreamBrokenBody' },
|
|
969
1070
|
'stream-ended': { key: 'errorStreamEndedBody' },
|
|
1071
|
+
// Feedback endpoints: these four are fully known Client-side, so an
|
|
1072
|
+
// English UI never shows the Host's Chinese line for them.
|
|
1073
|
+
'feedback-invalid': { key: 'errorFeedbackInvalid' },
|
|
1074
|
+
'feedback-unconfigured': { key: 'feedbackUnconfigured' },
|
|
1075
|
+
'feedback-too-fast': { key: 'feedbackTooFast' },
|
|
1076
|
+
'feedback-too-many': { key: 'feedbackTooMany' },
|
|
970
1077
|
}
|
|
971
1078
|
|
|
972
1079
|
/** One localized line for a wire error; unknown codes keep the Host text. */
|
|
@@ -1113,8 +1220,8 @@ window.__ModuleLoader__.load({
|
|
|
1113
1220
|
surface: 'flow',
|
|
1114
1221
|
surfaceNote: null,
|
|
1115
1222
|
/**
|
|
1116
|
-
* In-session override of `config.floatMode`, set by the
|
|
1117
|
-
* collapse
|
|
1223
|
+
* In-session override of `config.floatMode`, set by the card header's
|
|
1224
|
+
* collapse button. `null` = follow the configured default.
|
|
1118
1225
|
*/
|
|
1119
1226
|
floatMode: null,
|
|
1120
1227
|
sessionId: null,
|
|
@@ -1259,10 +1366,10 @@ window.__ModuleLoader__.load({
|
|
|
1259
1366
|
* NOT persisted: the settings page owns the durable default, and a reload
|
|
1260
1367
|
* should return to it (a pill hidden by accident must not stay hidden
|
|
1261
1368
|
* across sessions).
|
|
1262
|
-
* @param {string} mode - `capsule` | `full
|
|
1369
|
+
* @param {string} mode - `capsule` | `full`.
|
|
1263
1370
|
*/
|
|
1264
1371
|
function setFloatMode(mode) {
|
|
1265
|
-
if (mode !== 'capsule' && mode !== 'full'
|
|
1372
|
+
if (mode !== 'capsule' && mode !== 'full') return
|
|
1266
1373
|
store.set({ floatMode: mode })
|
|
1267
1374
|
}
|
|
1268
1375
|
|
|
@@ -1639,6 +1746,54 @@ window.__ModuleLoader__.load({
|
|
|
1639
1746
|
}
|
|
1640
1747
|
}
|
|
1641
1748
|
|
|
1749
|
+
/**
|
|
1750
|
+
* Validate a feedback draft BEFORE posting it, so the Host's 400 never
|
|
1751
|
+
* comes back as a surprise. The bounds are the live Host ones (see
|
|
1752
|
+
* {@link FEEDBACK_DEFAULTS}); the CR/LF rule mirrors the Host's own
|
|
1753
|
+
* header-injection fence.
|
|
1754
|
+
* @param {{kind?: string, title?: string, body?: string, contact?: string}} draft
|
|
1755
|
+
* @param {{titleMin: number, titleMax: number, bodyMin: number, bodyMax: number, contactMax?: number}} [limits]
|
|
1756
|
+
* @returns {{ok: true}|{ok: false, key: string}}
|
|
1757
|
+
*/
|
|
1758
|
+
function validateFeedbackDraft(draft, limits) {
|
|
1759
|
+
const bound = limits ?? FEEDBACK_DEFAULTS
|
|
1760
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1761
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1762
|
+
const chars = text => Array.from(text).length
|
|
1763
|
+
if (title.includes('\n') || title.includes('\r')) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1764
|
+
if (chars(title) < bound.titleMin || chars(title) > bound.titleMax) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1765
|
+
if (chars(body) < bound.bodyMin || chars(body) > bound.bodyMax) return { ok: false, key: 'feedbackNeedsBody' }
|
|
1766
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1767
|
+
if (bound.contactMax !== undefined && chars(contact) > bound.contactMax) return { ok: false, key: 'feedbackContactTooLong' }
|
|
1768
|
+
// Same pragmatic mailbox rule as the Host: the value becomes `Reply-To:`,
|
|
1769
|
+
// so anything that is not an address would be rejected by the relay.
|
|
1770
|
+
if (contact !== '' && !FEEDBACK_EMAIL_PATTERN.test(contact)) return { ok: false, key: 'feedbackContactInvalid' }
|
|
1771
|
+
return { ok: true }
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** One-line subject for the report. */
|
|
1775
|
+
function feedbackSubjectFor({ version, draft }) {
|
|
1776
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1777
|
+
const kind = typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'
|
|
1778
|
+
return `[dsh-sidecard-ask ${version}] ${kind}: ${title === '' ? 'feedback' : title}`
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** The report as plain text: what the copy button puts on the clipboard. */
|
|
1782
|
+
function feedbackPlainTextFor({ version, draft, userAgent }) {
|
|
1783
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1784
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1785
|
+
return [
|
|
1786
|
+
`kind: ${typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'}`,
|
|
1787
|
+
`title: ${typeof draft?.title === 'string' ? draft.title.trim() : ''}`,
|
|
1788
|
+
`contact: ${contact === '' ? '(not provided)' : contact}`,
|
|
1789
|
+
'',
|
|
1790
|
+
body,
|
|
1791
|
+
'',
|
|
1792
|
+
`plugin: ${version}`,
|
|
1793
|
+
`user agent: ${typeof userAgent === 'string' ? userAgent : ''}`,
|
|
1794
|
+
].join('\n')
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1642
1797
|
/**
|
|
1643
1798
|
* Validate a settings-page quick-action draft BEFORE posting it, so the
|
|
1644
1799
|
* Host's all-or-nothing refusal never comes back as a surprise. Also
|
|
@@ -2043,7 +2198,7 @@ window.__ModuleLoader__.load({
|
|
|
2043
2198
|
(\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
|
|
2044
2199
|
plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
|
|
2045
2200
|
Every surface we draw declares it. The property is inert in a browser. */
|
|
2046
|
-
.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}
|
|
2201
|
+
.dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-ctxmenu,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings,.dsa-confirm{-webkit-app-region:no-drag}
|
|
2047
2202
|
.dsa-layer{pointer-events:none;position:relative;z-index:60}
|
|
2048
2203
|
.dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
|
|
2049
2204
|
padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
@@ -2155,10 +2310,22 @@ body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
|
|
|
2155
2310
|
.dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
2156
2311
|
.dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
|
|
2157
2312
|
color:#fff;font-weight:500}
|
|
2313
|
+
/* Same reason as the segmented chips and the quick-bar primary: the uiskin dark
|
|
2314
|
+
brand is a light blue (#5b9cf5) where white text measures 2.79:1. Dark ink
|
|
2315
|
+
lands at 6.57:1 and keeps the button reading as "filled". */
|
|
2316
|
+
html[data-ds-dark-theme] .dsa-btn-primary,
|
|
2317
|
+
body[data-ds-dark-theme] .dsa-btn-primary{color:#0a1428}
|
|
2158
2318
|
.dsa-btn:disabled{opacity:.55;cursor:default}
|
|
2159
|
-
/* Icon-only variant (the floating layer's collapse/
|
|
2160
|
-
quiet so the card title bar stays one line and the title keeps the weight. */
|
|
2319
|
+
/* Icon-only variant (the floating layer's collapse / delete controls): square
|
|
2320
|
+
and quiet so the card title bar stays one line and the title keeps the weight. */
|
|
2161
2321
|
.dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2322
|
+
/* Destructive confirmation only: the irreversible button carries the error token
|
|
2323
|
+
so it can never be mistaken for the safe one rendered next to it. Placed after
|
|
2324
|
+
.dsa-btn-icon on purpose — same specificity, so source order decides. */
|
|
2325
|
+
.dsa-btn-danger{border-color:var(--dsw-alias-state-error-primary, #c0392b);
|
|
2326
|
+
background:var(--dsw-alias-state-error-primary, #c0392b);color:#fff;font-weight:500}
|
|
2327
|
+
.dsa-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary, #c0392b);
|
|
2328
|
+
background:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
2162
2329
|
/* Segmented control (main chat / side card). These are the two destinations an
|
|
2163
2330
|
answer can land in, so the pair has to read as a radio choice, not as two
|
|
2164
2331
|
quiet labels: the group gets a recessed track with an explicit mid-grey edge
|
|
@@ -2244,11 +2411,18 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2244
2411
|
.dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
|
|
2245
2412
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2246
2413
|
.dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
|
|
2247
|
-
.
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2414
|
+
/* Delete confirmation (2.0.0). Fixed, not inline: a card docked into the right
|
|
2415
|
+
rail lives inside an overflow:hidden page, which would clip a sheet anchored
|
|
2416
|
+
to the card. Same backdrop recipe as the feedback dialog. */
|
|
2417
|
+
.dsa-confirm-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;
|
|
2418
|
+
justify-content:center;padding:24px;background:rgba(0,0,0,.42)}
|
|
2419
|
+
.dsa-confirm{display:flex;flex-direction:column;gap:8px;width:min(360px, 100%);padding:14px 16px;
|
|
2420
|
+
border-radius:12px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
2421
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
2422
|
+
box-shadow:0 18px 48px rgba(0,0,0,.28)}
|
|
2423
|
+
.dsa-confirm-title{font-size:13px;font-weight:600}
|
|
2424
|
+
.dsa-confirm-body{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2425
|
+
.dsa-confirm > .dsa-row{justify-content:flex-end}
|
|
2252
2426
|
.dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
|
|
2253
2427
|
.dsa-answer .dsa-p{margin:0 0 6px}
|
|
2254
2428
|
.dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
|
|
@@ -2315,6 +2489,42 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2315
2489
|
.dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
|
|
2316
2490
|
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
2317
2491
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
2492
|
+
/* Feedback (settings page), v1.9.1 rebuild: one compact banner at the very top
|
|
2493
|
+
of the page, and a centered dialog for the form. The form used to be an
|
|
2494
|
+
always-visible inline panel here, which pushed every real setting below the
|
|
2495
|
+
fold; the SMTP transport form is gone from the page entirely — the Host reads
|
|
2496
|
+
it from the environment / user config, so there is nothing for an end user to
|
|
2497
|
+
fill in. The dialog is position:fixed with a high z-index because this
|
|
2498
|
+
section is rendered INSIDE the app's settings modal. */
|
|
2499
|
+
.dsa-feedback-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 14px;
|
|
2500
|
+
border-radius:12px;border:1px solid var(--dsw-alias-brand-primary, #4c8dff);
|
|
2501
|
+
background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))}
|
|
2502
|
+
.dsa-feedback-bar-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 240px}
|
|
2503
|
+
.dsa-feedback-bar-title{font-weight:600;color:var(--dsw-alias-label-primary, #111)}
|
|
2504
|
+
.dsa-feedback-bar-hint{font-size:12px;line-height:1.5;
|
|
2505
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2506
|
+
.dsa-feedback-bar > .dsa-btn{flex:0 0 auto;margin-left:auto}
|
|
2507
|
+
.dsa-feedback-backdrop{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;
|
|
2508
|
+
justify-content:center;padding:24px;background:rgba(0,0,0,.45)}
|
|
2509
|
+
.dsa-feedback-dialog{display:flex;flex-direction:column;gap:10px;width:min(560px, 100%);
|
|
2510
|
+
max-height:calc(100vh - 48px);overflow:auto;padding:16px 18px;border-radius:14px;
|
|
2511
|
+
border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
2512
|
+
background:var(--dsw-alias-bg-overlay, rgba(127,127,127,.03));color:inherit;
|
|
2513
|
+
box-shadow:0 18px 48px rgba(0,0,0,.28)}
|
|
2514
|
+
.dsa-feedback-dialog-head{display:flex;align-items:center;gap:8px}
|
|
2515
|
+
.dsa-feedback-dialog-head > strong{flex:1;min-width:0;font-size:14px}
|
|
2516
|
+
.dsa-feedback-dialog-head > .dsa-feedback-close{flex:0 0 auto;width:28px;height:28px;
|
|
2517
|
+
padding:0;line-height:1;font-size:16px}
|
|
2518
|
+
.dsa-feedback-dialog-field{display:flex;flex-direction:column;gap:4px}
|
|
2519
|
+
.dsa-feedback-dialog-field > label{font-size:12px;
|
|
2520
|
+
color:var(--dsw-alias-label-secondary, #555)}
|
|
2521
|
+
.dsa-feedback-dialog-field > textarea{min-height:120px;resize:vertical}
|
|
2522
|
+
.dsa-feedback-dialog-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
2523
|
+
.dsa-feedback-dialog-hint{font-size:12px;line-height:1.5;
|
|
2524
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2525
|
+
.dsa-feedback-state{font-size:12px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2526
|
+
.dsa-feedback-state[data-state="sent"]{color:var(--dsw-alias-state-success-primary, #1a7f37)}
|
|
2527
|
+
.dsa-feedback-state[data-state="error"]{color:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
2318
2528
|
/* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
|
|
2319
2529
|
dsh-selection-followup ≤1.0.x build. When this build is the elected
|
|
2320
2530
|
leader it hides the old build's whole interaction surface so exactly
|
|
@@ -3793,15 +4003,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3793
4003
|
}, t('close')))
|
|
3794
4004
|
}
|
|
3795
4005
|
|
|
4006
|
+
/**
|
|
4007
|
+
* The confirmation gate for an irreversible action.
|
|
4008
|
+
*
|
|
4009
|
+
* A component rather than an inline block for two reasons: every
|
|
4010
|
+
* destructive control can reuse it, and the test harness cannot drive a
|
|
4011
|
+
* click-driven state change, so rendering it directly is the only way this
|
|
4012
|
+
* markup gets asserted (same seam as {@link FeedbackDialog}).
|
|
4013
|
+
*
|
|
4014
|
+
* The safe choice is the default: Cancel takes focus, Escape aborts, and
|
|
4015
|
+
* only a click on the backdrop itself dismisses — a click that lands on the
|
|
4016
|
+
* dialog must not throw away the decision the user is making.
|
|
4017
|
+
*/
|
|
4018
|
+
function DeleteConfirmDialog({ title, body, detail, cancelLabel, confirmLabel, onCancel, onConfirm }) {
|
|
4019
|
+
return h('div', {
|
|
4020
|
+
className: 'dsa-confirm-backdrop',
|
|
4021
|
+
onClick: event => {
|
|
4022
|
+
if (event.target === event.currentTarget) onCancel()
|
|
4023
|
+
},
|
|
4024
|
+
onKeyDown: event => {
|
|
4025
|
+
if (event.key !== 'Escape') return
|
|
4026
|
+
// Escape is the safe exit, so it stops here: the card's own Esc
|
|
4027
|
+
// handler and the global shortcut layer must not also react.
|
|
4028
|
+
event.stopPropagation()
|
|
4029
|
+
onCancel()
|
|
4030
|
+
},
|
|
4031
|
+
},
|
|
4032
|
+
h('div', {
|
|
4033
|
+
className: 'dsa-confirm',
|
|
4034
|
+
role: 'dialog',
|
|
4035
|
+
'aria-modal': 'true',
|
|
4036
|
+
'aria-label': title,
|
|
4037
|
+
},
|
|
4038
|
+
h('strong', { className: 'dsa-confirm-title' }, title),
|
|
4039
|
+
h('div', { className: 'dsa-confirm-body' }, body),
|
|
4040
|
+
detail !== null && detail !== undefined && detail !== '' ? h('div', { className: 'dsa-muted' }, detail) : null,
|
|
4041
|
+
h('div', { className: 'dsa-row' },
|
|
4042
|
+
h('button', {
|
|
4043
|
+
type: 'button',
|
|
4044
|
+
className: 'dsa-btn',
|
|
4045
|
+
// Safe default: a stray Enter on an unreviewed dialog must not
|
|
4046
|
+
// destroy anything.
|
|
4047
|
+
autoFocus: true,
|
|
4048
|
+
onClick: onCancel,
|
|
4049
|
+
}, cancelLabel),
|
|
4050
|
+
h('button', {
|
|
4051
|
+
type: 'button',
|
|
4052
|
+
className: 'dsa-btn dsa-btn-danger',
|
|
4053
|
+
onClick: onConfirm,
|
|
4054
|
+
}, confirmLabel))))
|
|
4055
|
+
}
|
|
4056
|
+
|
|
3796
4057
|
/**
|
|
3797
4058
|
* One answer card; `embedded` is true inside a host's tab.
|
|
3798
4059
|
*
|
|
3799
|
-
* `onFloatMode`
|
|
3800
|
-
* collapse
|
|
3801
|
-
* (the layer is the only surface that can cover the
|
|
3802
|
-
*
|
|
4060
|
+
* `onFloatMode` / `onDelete` are passed ONLY by the floating layer. The
|
|
4061
|
+
* first renders the "collapse to a capsule" control that gets the layer out
|
|
4062
|
+
* of the way (the layer is the only surface that can cover the
|
|
4063
|
+
* conversation); the second renders the × that DELETES this follow-up
|
|
4064
|
+
* conversation — its live card *and* its durable history entry — after a
|
|
4065
|
+
* confirmation. A host tab that embeds the same card passes neither and
|
|
4066
|
+
* keeps its own tab close plus the footer Close button, which by design
|
|
4067
|
+
* detaches the card without touching the history.
|
|
3803
4068
|
*/
|
|
3804
|
-
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
|
|
4069
|
+
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onDelete, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
|
|
3805
4070
|
const [followOpen, setFollowOpen] = React.useState(false)
|
|
3806
4071
|
const [draft, setDraft] = React.useState('')
|
|
3807
4072
|
// Edit-&-retry editor: prefilled with the card's CURRENT question and
|
|
@@ -3809,6 +4074,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3809
4074
|
// edit-retry regenerates THIS turn, keeping the card's earlier turns).
|
|
3810
4075
|
const [editOpen, setEditOpen] = React.useState(false)
|
|
3811
4076
|
const [editDraft, setEditDraft] = React.useState('')
|
|
4077
|
+
// Deleting a follow-up conversation is irreversible, so it goes through an
|
|
4078
|
+
// in-plugin confirmation — EXCEPT for a card that holds nothing: no
|
|
4079
|
+
// follow-up turn, no answer text, no reasoning, no run in flight. There is
|
|
4080
|
+
// nothing to lose there, and prompting on an empty card only trains the
|
|
4081
|
+
// user to dismiss the prompt that matters.
|
|
4082
|
+
const [deleteOpen, setDeleteOpen] = React.useState(false)
|
|
3812
4083
|
const answerRef = React.useRef(null)
|
|
3813
4084
|
const config = store.state.config
|
|
3814
4085
|
// P2-5b: an older version is READ-ONLY content swapped into the same
|
|
@@ -3843,6 +4114,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3843
4114
|
// without modifiers and never while composing or inside an editor.
|
|
3844
4115
|
const onCardKeyDown = (event) => {
|
|
3845
4116
|
if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return
|
|
4117
|
+
// The confirmation is modal: R/C must not act on a card the user is
|
|
4118
|
+
// currently deciding to delete.
|
|
4119
|
+
if (deleteOpen) return
|
|
3846
4120
|
const tag = String(event.target?.tagName ?? '')
|
|
3847
4121
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON'
|
|
3848
4122
|
|| event.target?.closest?.('[contenteditable=""],[contenteditable="true"]') !== null) {
|
|
@@ -3871,6 +4145,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3871
4145
|
if (typeof card.elapsedMs === 'number' && Number.isFinite(card.elapsedMs) && formatElapsed(card.elapsedMs) !== '') {
|
|
3872
4146
|
metaBits.push(`${t('metaElapsed')} ${formatElapsed(card.elapsedMs)}`)
|
|
3873
4147
|
}
|
|
4148
|
+
// "Empty" is judged on the card's own content, never on how it got here.
|
|
4149
|
+
const turns = Array.isArray(card.history) ? card.history.length : 0
|
|
4150
|
+
const busy = card.status === 'pending' || card.status === 'streaming'
|
|
4151
|
+
const nothingToLose = turns === 0
|
|
4152
|
+
&& String(card.text ?? '').trim() === ''
|
|
4153
|
+
&& String(card.reasoning ?? '').trim() === ''
|
|
4154
|
+
const requestDelete = () => {
|
|
4155
|
+
if (!busy && nothingToLose) {
|
|
4156
|
+
onDelete?.()
|
|
4157
|
+
return
|
|
4158
|
+
}
|
|
4159
|
+
setDeleteOpen(true)
|
|
4160
|
+
}
|
|
3874
4161
|
return h('section', {
|
|
3875
4162
|
className: 'dsa-card',
|
|
3876
4163
|
'aria-label': t('answerTitle'),
|
|
@@ -3883,10 +4170,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3883
4170
|
h('span', { className: 'dsa-badge' }, zoneLabel(card)),
|
|
3884
4171
|
surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
|
|
3885
4172
|
card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
|
|
3886
|
-
typeof onFloatMode === 'function'
|
|
4173
|
+
typeof onFloatMode === 'function' || typeof onDelete === 'function'
|
|
3887
4174
|
? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
|
|
3888
|
-
|
|
3889
|
-
|
|
4175
|
+
typeof onFloatMode === 'function'
|
|
4176
|
+
? floatControl('capsule', 'floatCollapse', '⇥')
|
|
4177
|
+
: null,
|
|
4178
|
+
typeof onDelete === 'function'
|
|
4179
|
+
? h('button', {
|
|
4180
|
+
type: 'button',
|
|
4181
|
+
className: 'dsa-btn dsa-btn-icon',
|
|
4182
|
+
title: t('cardDelete'),
|
|
4183
|
+
'aria-label': t('cardDelete'),
|
|
4184
|
+
onClick: () => requestDelete(),
|
|
4185
|
+
}, '×')
|
|
4186
|
+
: null)
|
|
3890
4187
|
: null),
|
|
3891
4188
|
h('pre', { className: 'dsa-quote' }, shown.question),
|
|
3892
4189
|
versions.length > 0
|
|
@@ -4037,17 +4334,34 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4037
4334
|
onFollowUp: () => setFollowOpen(open => !open),
|
|
4038
4335
|
onClose,
|
|
4039
4336
|
}),
|
|
4040
|
-
surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null
|
|
4337
|
+
surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null,
|
|
4338
|
+
deleteOpen
|
|
4339
|
+
? h(DeleteConfirmDialog, {
|
|
4340
|
+
title: t('cardDeleteTitle'),
|
|
4341
|
+
body: t('cardDeleteBody'),
|
|
4342
|
+
detail: turns > 0 ? t('cardDeleteRounds', { n: turns }) : null,
|
|
4343
|
+
cancelLabel: t('askCancel'),
|
|
4344
|
+
confirmLabel: t('cardDeleteConfirm'),
|
|
4345
|
+
onCancel: () => setDeleteOpen(false),
|
|
4346
|
+
onConfirm: () => {
|
|
4347
|
+
setDeleteOpen(false)
|
|
4348
|
+
onDelete?.()
|
|
4349
|
+
},
|
|
4350
|
+
})
|
|
4351
|
+
: null)
|
|
4041
4352
|
}
|
|
4042
4353
|
|
|
4043
4354
|
/**
|
|
4044
4355
|
* The flow surface, i.e. the floating layer (A).
|
|
4045
4356
|
*
|
|
4046
|
-
*
|
|
4357
|
+
* Two shapes, both sharing one right-anchored container:
|
|
4047
4358
|
* - `capsule`: a single ~34px pill (status + truncated question). The
|
|
4048
4359
|
* default, because a one-line pill cannot bury the conversation.
|
|
4049
4360
|
* - `full`: the classic card stack, collapsed on demand.
|
|
4050
|
-
*
|
|
4361
|
+
*
|
|
4362
|
+
* The former `off` shape (hide the layer behind a small launcher) is gone
|
|
4363
|
+
* as of 2.0.0: the card header's × deletes that follow-up conversation
|
|
4364
|
+
* instead, and "hide everything" was never a state this layer needed.
|
|
4051
4365
|
*
|
|
4052
4366
|
* The width comes from {@link floatPlacement}, so the layer stops growing
|
|
4053
4367
|
* left as soon as it would cross the conversation column's right boundary.
|
|
@@ -4248,17 +4562,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4248
4562
|
onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
|
|
4249
4563
|
onViewVersion: index => actions.viewVersion(card, index),
|
|
4250
4564
|
onFloatMode: setFloatMode,
|
|
4565
|
+
onDelete: () => actions.deleteCard(card),
|
|
4251
4566
|
}))
|
|
4252
4567
|
|
|
4253
|
-
if (mode === 'off') {
|
|
4254
|
-
return container(h('button', {
|
|
4255
|
-
type: 'button',
|
|
4256
|
-
className: 'dsa-launcher',
|
|
4257
|
-
title: t('floatRestore'),
|
|
4258
|
-
onClick: () => setFloatMode('full'),
|
|
4259
|
-
}, dot, t('floatCount', { n: total })))
|
|
4260
|
-
}
|
|
4261
|
-
|
|
4262
4568
|
if (mode === 'capsule') {
|
|
4263
4569
|
return container(h('button', {
|
|
4264
4570
|
type: 'button',
|
|
@@ -4272,8 +4578,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4272
4578
|
|
|
4273
4579
|
// Docked: the card takes the rail's whole rectangle, so it reads as one of
|
|
4274
4580
|
// the rail's pages rather than a 340px card hovering over it. The card
|
|
4275
|
-
// keeps its own header (title + badges + collapse/
|
|
4276
|
-
// what makes the page dismissible without leaving the rail.
|
|
4581
|
+
// keeps its own header (title + badges + collapse / delete controls),
|
|
4582
|
+
// which is what makes the page dismissible without leaving the rail.
|
|
4277
4583
|
return container(h('div', {
|
|
4278
4584
|
className: 'dsa-stack',
|
|
4279
4585
|
'data-dsa-dock-body': docked ? String(dock.width) : undefined,
|
|
@@ -4328,6 +4634,136 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4328
4634
|
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
4329
4635
|
}
|
|
4330
4636
|
|
|
4637
|
+
/**
|
|
4638
|
+
* The feedback dialog (`role="dialog"`).
|
|
4639
|
+
*
|
|
4640
|
+
* Module scope on purpose: the settings page only mounts it while open, and
|
|
4641
|
+
* the contract test renders it directly — the test React shim has no working
|
|
4642
|
+
* setState, so a click-driven open cannot be exercised through the page.
|
|
4643
|
+
* Everything it needs arrives through props, so it is also the one surface
|
|
4644
|
+
* that can be checked without a Host.
|
|
4645
|
+
*/
|
|
4646
|
+
function FeedbackDialog(props) {
|
|
4647
|
+
const {
|
|
4648
|
+
t, limits, draft, onChange, status, error, ready, reason, hasRecipient,
|
|
4649
|
+
onClose, onSubmit, onCopy,
|
|
4650
|
+
} = props
|
|
4651
|
+
const sending = status === 'sending'
|
|
4652
|
+
// Escape closes it: a modal without a keyboard exit is a trap.
|
|
4653
|
+
React.useEffect(() => {
|
|
4654
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
|
|
4655
|
+
return undefined
|
|
4656
|
+
}
|
|
4657
|
+
const onKey = event => { if (event !== null && event?.key === 'Escape') onClose() }
|
|
4658
|
+
document.addEventListener('keydown', onKey)
|
|
4659
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
4660
|
+
}, [onClose])
|
|
4661
|
+
|
|
4662
|
+
const field = (label, control) => h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4663
|
+
h('label', null, label),
|
|
4664
|
+
control)
|
|
4665
|
+
|
|
4666
|
+
return h('div', {
|
|
4667
|
+
className: 'dsa-feedback-backdrop',
|
|
4668
|
+
'data-dsa-feedback-dialog': '',
|
|
4669
|
+
onClick: event => {
|
|
4670
|
+
// Only a click on the backdrop itself closes; clicks inside the card
|
|
4671
|
+
// bubble here and must not dismiss a half-written report.
|
|
4672
|
+
if (event !== null && event !== undefined && event.target === event.currentTarget) onClose()
|
|
4673
|
+
},
|
|
4674
|
+
},
|
|
4675
|
+
h('div', {
|
|
4676
|
+
className: 'dsa-feedback-dialog',
|
|
4677
|
+
role: 'dialog',
|
|
4678
|
+
'aria-modal': 'true',
|
|
4679
|
+
'aria-label': t('feedbackDialogTitle'),
|
|
4680
|
+
},
|
|
4681
|
+
h('div', { className: 'dsa-feedback-dialog-head' },
|
|
4682
|
+
h('strong', null, t('feedbackDialogTitle')),
|
|
4683
|
+
h('button', {
|
|
4684
|
+
type: 'button',
|
|
4685
|
+
className: 'dsa-btn dsa-feedback-close',
|
|
4686
|
+
'data-feedback-close': '',
|
|
4687
|
+
title: t('feedbackClose'),
|
|
4688
|
+
'aria-label': t('feedbackClose'),
|
|
4689
|
+
onClick: onClose,
|
|
4690
|
+
}, '×')),
|
|
4691
|
+
h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4692
|
+
h('label', null, t('feedbackKind')),
|
|
4693
|
+
h('select', {
|
|
4694
|
+
className: 'dsa-input',
|
|
4695
|
+
'data-feedback-field': 'kind',
|
|
4696
|
+
value: draft.kind,
|
|
4697
|
+
onChange: event => onChange({ kind: event.target.value }),
|
|
4698
|
+
}, [
|
|
4699
|
+
['bug', t('feedbackKindBug')],
|
|
4700
|
+
['idea', t('feedbackKindIdea')],
|
|
4701
|
+
['question', t('feedbackKindQuestion')],
|
|
4702
|
+
['other', t('feedbackKindOther')],
|
|
4703
|
+
].map(([value, label]) => h('option', { key: value, value }, label)))),
|
|
4704
|
+
field(t('feedbackTitle'), h('input', {
|
|
4705
|
+
className: 'dsa-input',
|
|
4706
|
+
'data-feedback-field': 'title',
|
|
4707
|
+
placeholder: t('feedbackTitlePlaceholder'),
|
|
4708
|
+
maxLength: limits.titleMax,
|
|
4709
|
+
value: draft.title,
|
|
4710
|
+
onChange: event => onChange({ title: event.target.value }),
|
|
4711
|
+
})),
|
|
4712
|
+
field(t('feedbackBody'), h('textarea', {
|
|
4713
|
+
className: 'dsa-textarea',
|
|
4714
|
+
'data-feedback-field': 'body',
|
|
4715
|
+
placeholder: t('feedbackBodyPlaceholder'),
|
|
4716
|
+
maxLength: limits.bodyMax,
|
|
4717
|
+
value: draft.body,
|
|
4718
|
+
onChange: event => onChange({ body: event.target.value }),
|
|
4719
|
+
})),
|
|
4720
|
+
// The reporter's own address becomes the mail's `Reply-To:`, never its
|
|
4721
|
+
// `From:` — the relay authenticates as the owner's account and would
|
|
4722
|
+
// reject a foreign sender.
|
|
4723
|
+
field(t('feedbackContact'), h('input', {
|
|
4724
|
+
className: 'dsa-input',
|
|
4725
|
+
'data-feedback-field': 'contact',
|
|
4726
|
+
type: 'email',
|
|
4727
|
+
inputMode: 'email',
|
|
4728
|
+
autoComplete: 'email',
|
|
4729
|
+
placeholder: t('feedbackContactPlaceholder'),
|
|
4730
|
+
maxLength: limits.contactMax,
|
|
4731
|
+
value: draft.contact,
|
|
4732
|
+
onChange: event => onChange({ contact: event.target.value }),
|
|
4733
|
+
})),
|
|
4734
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackContactHint')),
|
|
4735
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackEnvNote')),
|
|
4736
|
+
// Readiness: two states, not one. No recipient at all, or a recipient
|
|
4737
|
+
// but no usable transport. When the Host explains why, print its
|
|
4738
|
+
// sentence verbatim.
|
|
4739
|
+
ready === true
|
|
4740
|
+
? null
|
|
4741
|
+
: h('span', { className: 'dsa-feedback-dialog-hint', 'data-feedback-readiness': 'unready' },
|
|
4742
|
+
String(reason ?? '') !== ''
|
|
4743
|
+
? String(reason)
|
|
4744
|
+
: (hasRecipient === true ? t('feedbackNoCredentials') : t('feedbackUnconfigured'))),
|
|
4745
|
+
h('div', { className: 'dsa-feedback-dialog-actions' },
|
|
4746
|
+
h('button', {
|
|
4747
|
+
type: 'button',
|
|
4748
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
4749
|
+
'data-feedback-submit': '',
|
|
4750
|
+
disabled: sending,
|
|
4751
|
+
title: ready === true ? undefined : t('feedbackUnconfigured'),
|
|
4752
|
+
onClick: onSubmit,
|
|
4753
|
+
}, sending ? t('feedbackSending') : t('feedbackSend')),
|
|
4754
|
+
h('button', {
|
|
4755
|
+
type: 'button',
|
|
4756
|
+
className: 'dsa-btn',
|
|
4757
|
+
'data-feedback-copy': '',
|
|
4758
|
+
onClick: onCopy,
|
|
4759
|
+
}, t('feedbackCopy'))),
|
|
4760
|
+
status === 'sent'
|
|
4761
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'sent', role: 'status' }, t('feedbackSent'))
|
|
4762
|
+
: status === 'error'
|
|
4763
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'error', role: 'status' }, error)
|
|
4764
|
+
: null))
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4331
4767
|
/** The settings page (`settings.section`). */
|
|
4332
4768
|
function SettingsSection({ actions }) {
|
|
4333
4769
|
const state = useStoreState()
|
|
@@ -4335,6 +4771,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4335
4771
|
const [status, setStatus] = React.useState('idle')
|
|
4336
4772
|
const [diag, setDiag] = React.useState(null)
|
|
4337
4773
|
const [historyQuery, setHistoryQuery] = React.useState('')
|
|
4774
|
+
// ── feedback form ──────────────────────────────────────────────────
|
|
4775
|
+
// `idle` → typing; `sending` → one POST in flight (button locked);
|
|
4776
|
+
// `sent` → the draft was cleared; `error` → the draft is KEPT, so a
|
|
4777
|
+
// relay failure never eats what the user just wrote.
|
|
4778
|
+
// Closed by default: the entry must be the first thing on the page and
|
|
4779
|
+
// one click away, but an always-open form pushed every real setting below
|
|
4780
|
+
// the fold (the previous version did exactly that and read as clutter).
|
|
4781
|
+
const [feedbackOpen, setFeedbackOpen] = React.useState(false)
|
|
4782
|
+
const [feedbackDraft, setFeedbackDraft] = React.useState({ kind: 'bug', title: '', body: '', contact: '' })
|
|
4783
|
+
const [feedbackStatus, setFeedbackStatus] = React.useState('idle')
|
|
4784
|
+
const [feedbackError, setFeedbackError] = React.useState('')
|
|
4785
|
+
// Editing is write-only: the saved address is never sent back to this page
|
|
4786
|
+
// (the Host strips it from `/state`), so the form can only replace it.
|
|
4787
|
+
const feedbackInfo = state.capabilities?.feedback ?? null
|
|
4788
|
+
const feedbackSmtp = feedbackInfo?.smtp ?? null
|
|
4789
|
+
const feedbackLimits = feedbackInfo?.limits ?? {
|
|
4790
|
+
titleMin: 4, titleMax: 120, bodyMin: 10, bodyMax: 4000, contactMax: 120,
|
|
4791
|
+
}
|
|
4338
4792
|
const [notifyPerm, setNotifyPerm] = React.useState(
|
|
4339
4793
|
typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
|
|
4340
4794
|
const config = state.config
|
|
@@ -4362,6 +4816,44 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4362
4816
|
control,
|
|
4363
4817
|
hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
|
|
4364
4818
|
|
|
4819
|
+
// ── feedback actions ───────────────────────────────────────────────
|
|
4820
|
+
const feedbackUserAgent = typeof navigator === 'undefined' ? '' : String(navigator.userAgent ?? '')
|
|
4821
|
+
|
|
4822
|
+
const submitFeedback = async () => {
|
|
4823
|
+
if (feedbackStatus === 'sending') return
|
|
4824
|
+
const verdict = validateFeedbackDraft(feedbackDraft, feedbackLimits)
|
|
4825
|
+
if (verdict.ok !== true) {
|
|
4826
|
+
setFeedbackStatus('error')
|
|
4827
|
+
setFeedbackError(t(verdict.key, { n: feedbackLimits.contactMax ?? FEEDBACK_DEFAULTS.contactMax }))
|
|
4828
|
+
return
|
|
4829
|
+
}
|
|
4830
|
+
setFeedbackStatus('sending')
|
|
4831
|
+
setFeedbackError('')
|
|
4832
|
+
try {
|
|
4833
|
+
await postJson('feedback', {
|
|
4834
|
+
kind: feedbackDraft.kind,
|
|
4835
|
+
title: feedbackDraft.title.trim(),
|
|
4836
|
+
body: feedbackDraft.body.trim(),
|
|
4837
|
+
contact: feedbackDraft.contact.trim(),
|
|
4838
|
+
})
|
|
4839
|
+
setFeedbackStatus('sent')
|
|
4840
|
+
setFeedbackDraft({ kind: feedbackDraft.kind, title: '', body: '', contact: '' })
|
|
4841
|
+
} catch (error) {
|
|
4842
|
+
setFeedbackStatus('error')
|
|
4843
|
+
setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
const copyFeedbackDraft = async () => {
|
|
4848
|
+
const text = feedbackPlainTextFor({
|
|
4849
|
+
version: state.version ?? CLIENT_VERSION,
|
|
4850
|
+
draft: feedbackDraft,
|
|
4851
|
+
userAgent: feedbackUserAgent,
|
|
4852
|
+
})
|
|
4853
|
+
const result = await copyText(text, null)
|
|
4854
|
+
toast(result === 'copied' ? t('feedbackCopied') : t('copyFailed'))
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4365
4857
|
const select = (key, options) => h('select', {
|
|
4366
4858
|
className: 'dsa-input',
|
|
4367
4859
|
value: String(draft[key]),
|
|
@@ -4588,6 +5080,44 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4588
5080
|
? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
|
|
4589
5081
|
: null)
|
|
4590
5082
|
|
|
5083
|
+
// ── feedback entry ─────────────────────────────────────────────────
|
|
5084
|
+
// One compact banner at the very top of the page, and a dialog for the
|
|
5085
|
+
// form. Nothing else about feedback lives on this page: the transport is
|
|
5086
|
+
// the Host's business (environment / user config), so an end user has
|
|
5087
|
+
// nothing to fill in before writing a report. The banner also works when
|
|
5088
|
+
// no SMTP endpoint is configured — the dialog says so in one line and
|
|
5089
|
+
// points at the copy path instead of looking broken.
|
|
5090
|
+
const feedbackConfigured = feedbackInfo !== null && feedbackInfo.configured === true
|
|
5091
|
+
const feedbackBanner = h('div', { className: 'dsa-feedback-bar', 'data-dsa-feedback-section': '' },
|
|
5092
|
+
h('div', { className: 'dsa-feedback-bar-text' },
|
|
5093
|
+
h('div', { className: 'dsa-feedback-bar-title' }, t('setFeedback')),
|
|
5094
|
+
h('div', { className: 'dsa-feedback-bar-hint' }, t('setFeedbackHint'))),
|
|
5095
|
+
h('button', {
|
|
5096
|
+
type: 'button',
|
|
5097
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
5098
|
+
'data-feedback-open': '',
|
|
5099
|
+
'aria-haspopup': 'dialog',
|
|
5100
|
+
'aria-expanded': feedbackOpen ? 'true' : 'false',
|
|
5101
|
+
onClick: () => setFeedbackOpen(true),
|
|
5102
|
+
}, t('feedbackOpen')))
|
|
5103
|
+
|
|
5104
|
+
const feedbackDialog = feedbackOpen !== true
|
|
5105
|
+
? null
|
|
5106
|
+
: h(FeedbackDialog, {
|
|
5107
|
+
t,
|
|
5108
|
+
limits: feedbackLimits,
|
|
5109
|
+
draft: feedbackDraft,
|
|
5110
|
+
onChange: patch => setFeedbackDraft({ ...feedbackDraft, ...patch }),
|
|
5111
|
+
status: feedbackStatus,
|
|
5112
|
+
error: feedbackError,
|
|
5113
|
+
ready: feedbackConfigured,
|
|
5114
|
+
reason: String(feedbackInfo?.reason ?? ''),
|
|
5115
|
+
hasRecipient: feedbackInfo?.hasRecipient === true,
|
|
5116
|
+
onClose: () => setFeedbackOpen(false),
|
|
5117
|
+
onSubmit: () => { void submitFeedback() },
|
|
5118
|
+
onCopy: () => { void copyFeedbackDraft() },
|
|
5119
|
+
})
|
|
5120
|
+
|
|
4591
5121
|
// NOT marked `data-dsa-rail-host`: the settings page is not a rail page,
|
|
4592
5122
|
// and marking it would let the floating layer dock over the settings panel.
|
|
4593
5123
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
@@ -4595,6 +5125,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4595
5125
|
h('div', null,
|
|
4596
5126
|
h('div', { className: 'dsa-title' }, t('settingsTitle')),
|
|
4597
5127
|
h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
|
|
5128
|
+
feedbackBanner,
|
|
4598
5129
|
familyBanner,
|
|
4599
5130
|
h('div', { className: 'dsa-grid' },
|
|
4600
5131
|
field(t('setTrigger'), select('trigger', [
|
|
@@ -4622,7 +5153,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4622
5153
|
field(t('setFloatMode'), select('floatMode', [
|
|
4623
5154
|
['capsule', t('setFloatModeCapsule')],
|
|
4624
5155
|
['full', t('setFloatModeFull')],
|
|
4625
|
-
['off', t('setFloatModeOff')],
|
|
4626
5156
|
]), t('setFloatModeHint')),
|
|
4627
5157
|
field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
|
|
4628
5158
|
field(t('setMinChars'), number('minChars', 0, 200)),
|
|
@@ -4753,7 +5283,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4753
5283
|
h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSurfaces()}`),
|
|
4754
5284
|
h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
|
|
4755
5285
|
])
|
|
4756
|
-
: null
|
|
5286
|
+
: null,
|
|
5287
|
+
feedbackDialog)
|
|
4757
5288
|
}
|
|
4758
5289
|
|
|
4759
5290
|
/** Session probe: an invisible entry in the composer tool row. */
|
|
@@ -5092,7 +5623,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5092
5623
|
} else {
|
|
5093
5624
|
store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
|
|
5094
5625
|
}
|
|
5095
|
-
// A
|
|
5626
|
+
// A collapsed (capsule) layer would swallow the restored card;
|
|
5096
5627
|
// this is a session-only override, exactly like clicking expand.
|
|
5097
5628
|
setFloatMode('full')
|
|
5098
5629
|
toast(t('historyRestored'))
|
|
@@ -5110,6 +5641,57 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5110
5641
|
})
|
|
5111
5642
|
toast(t('historyDeleted'))
|
|
5112
5643
|
},
|
|
5644
|
+
/**
|
|
5645
|
+
* Delete ONE follow-up conversation: the live card AND its durable
|
|
5646
|
+
* history entry. This is the card header's ×.
|
|
5647
|
+
*
|
|
5648
|
+
* Deliberately stronger than {@link actions.close}: closing only
|
|
5649
|
+
* detaches the card from the live surface and keeps the history entry
|
|
5650
|
+
* on purpose (#6), while this removes the same id from both stores and
|
|
5651
|
+
* persists immediately, so a reload cannot resurrect it.
|
|
5652
|
+
*
|
|
5653
|
+
* An in-flight run is released first: the browser stream is aborted,
|
|
5654
|
+
* the Host child is told to cancel, and the card's buffered deltas are
|
|
5655
|
+
* dropped — a late flush would otherwise re-append text to a card that
|
|
5656
|
+
* no longer exists.
|
|
5657
|
+
*/
|
|
5658
|
+
deleteCard(card) {
|
|
5659
|
+
const id = card?.id
|
|
5660
|
+
if (typeof id !== 'string' || id === '') return false
|
|
5661
|
+
const controller = controllers.get(id)
|
|
5662
|
+
if (controller !== undefined) {
|
|
5663
|
+
controller.abort()
|
|
5664
|
+
void postJson('cancel', { id }).catch(() => {})
|
|
5665
|
+
}
|
|
5666
|
+
controllers.delete(id)
|
|
5667
|
+
pendingDeltas.delete(id)
|
|
5668
|
+
pendingReasonings.delete(id)
|
|
5669
|
+
// Every surface gets the same chance to close its own container as
|
|
5670
|
+
// it does on close(): an orphaned tab reads to the user as a card
|
|
5671
|
+
// that refused to die.
|
|
5672
|
+
if (card.surface === 'better-sidebar') {
|
|
5673
|
+
try {
|
|
5674
|
+
surfaces.better.close(card)
|
|
5675
|
+
} catch { /* the host may already be gone */ }
|
|
5676
|
+
} else if (card.surface === 'native-rightbar') {
|
|
5677
|
+
try {
|
|
5678
|
+
surfaces.native.close(card)
|
|
5679
|
+
} catch { /* the right rail owns its lifecycle */ }
|
|
5680
|
+
}
|
|
5681
|
+
const next = store.state.history
|
|
5682
|
+
.filter(item => item.id !== id)
|
|
5683
|
+
.slice(0, historyCap())
|
|
5684
|
+
// cap === 0 means "nothing is persisted": leave the stored key alone
|
|
5685
|
+
// rather than writing an empty payload over it (same rule as
|
|
5686
|
+
// syncPersisted).
|
|
5687
|
+
if (historyCap() > 0) writePersisted(next)
|
|
5688
|
+
store.set({
|
|
5689
|
+
history: next,
|
|
5690
|
+
cards: store.state.cards.filter(item => item.id !== id),
|
|
5691
|
+
})
|
|
5692
|
+
toast(t('cardDeleted'))
|
|
5693
|
+
return true
|
|
5694
|
+
},
|
|
5113
5695
|
/**
|
|
5114
5696
|
* Empty the durable history without touching running cards.
|
|
5115
5697
|
*
|
|
@@ -5491,6 +6073,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5491
6073
|
problems: value.problems ?? [],
|
|
5492
6074
|
provenance: value.provenance ?? null,
|
|
5493
6075
|
})
|
|
6076
|
+
// Readiness hints (the feedback card's "缺的只是发信凭据", the surface
|
|
6077
|
+
// list, → live in `capabilities`, which only `/state` reports. Without
|
|
6078
|
+
// this refresh the page kept telling the user the transport was
|
|
6079
|
+
// missing right after they had saved it.
|
|
6080
|
+
try {
|
|
6081
|
+
await refreshState()
|
|
6082
|
+
} catch {
|
|
6083
|
+
// The save itself succeeded; a failed re-read must not undo that.
|
|
6084
|
+
}
|
|
5494
6085
|
return value
|
|
5495
6086
|
},
|
|
5496
6087
|
async resetConfig() {
|
|
@@ -6271,6 +6862,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6271
6862
|
}, 'sidecard-ask: client activation')
|
|
6272
6863
|
},
|
|
6273
6864
|
|
|
6865
|
+
/**
|
|
6866
|
+
* Test seam: components that can be rendered on their own. The feedback
|
|
6867
|
+
* dialog and the delete confirmation both live here because their host
|
|
6868
|
+
* mounts them only while open and the test shim has no working setState,
|
|
6869
|
+
* so a simulated click cannot reach them.
|
|
6870
|
+
*/
|
|
6871
|
+
components: Object.freeze({ FeedbackDialog, DeleteConfirmDialog }),
|
|
6872
|
+
|
|
6274
6873
|
/**
|
|
6275
6874
|
* Test seam: pure helpers, frozen. The DSH client runtime only reads
|
|
6276
6875
|
* `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
|
|
@@ -6294,6 +6893,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6294
6893
|
quickActionsOf,
|
|
6295
6894
|
renderActionQuestion,
|
|
6296
6895
|
validateQuickActionsDraft,
|
|
6896
|
+
validateFeedbackDraft,
|
|
6897
|
+
feedbackSubjectFor,
|
|
6898
|
+
feedbackPlainTextFor,
|
|
6899
|
+
FEEDBACK_DEFAULTS,
|
|
6297
6900
|
shortcutFingerprint,
|
|
6298
6901
|
followUpChips,
|
|
6299
6902
|
formatElapsed,
|