dsh-sidecard-ask 1.8.3 → 1.9.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 +132 -0
- package/README.md +134 -4
- package/client.js +538 -9
- package/index.js +772 -8
- 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.9.1'
|
|
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
|
|
@@ -465,6 +496,7 @@ window.__ModuleLoader__.load({
|
|
|
465
496
|
errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
|
|
466
497
|
errorTooLarge: '请求体过大',
|
|
467
498
|
errorBadRequest: '请求无效(问题或选中文本为空)',
|
|
499
|
+
errorFeedbackInvalid: '反馈内容不符合要求,检查标题与详细描述的长度',
|
|
468
500
|
errorUnloaded: '插件正在卸载,无法受理新的追问',
|
|
469
501
|
errorDuplicate: '该追问已在处理中,请等它完成',
|
|
470
502
|
errorBusy: '并发追问已达上限({n}),请稍后再试',
|
|
@@ -656,6 +688,49 @@ window.__ModuleLoader__.load({
|
|
|
656
688
|
suggestionsLabel: '建议追问',
|
|
657
689
|
familyLegacyDiag: '旧版共载',
|
|
658
690
|
familyPeers: '同页实例',
|
|
691
|
+
setFeedback: '反馈',
|
|
692
|
+
setFeedbackHint: '遇到问题或有建议,在这里直接发邮件给我;邮件会自动附带插件版本与运行环境(不含聊天内容)',
|
|
693
|
+
feedbackOpen: '反馈问题',
|
|
694
|
+
feedbackClose: '关闭',
|
|
695
|
+
feedbackDialogTitle: '发送反馈',
|
|
696
|
+
feedbackKind: '问题类型',
|
|
697
|
+
feedbackKindBug: '缺陷',
|
|
698
|
+
feedbackKindIdea: '建议',
|
|
699
|
+
feedbackKindQuestion: '疑问',
|
|
700
|
+
feedbackKindOther: '其他',
|
|
701
|
+
feedbackTitle: '标题',
|
|
702
|
+
feedbackTitlePlaceholder: '一句话说明问题(4–120 字)',
|
|
703
|
+
feedbackBody: '详细描述',
|
|
704
|
+
feedbackBodyPlaceholder: '复现步骤 / 期望结果 / 实际结果(10–4000 字)',
|
|
705
|
+
feedbackContact: '你的邮箱(选填,作为回信地址)',
|
|
706
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
707
|
+
feedbackContactHint: '填了维护者就能直接回信给你;留空即匿名。',
|
|
708
|
+
feedbackContactInvalid: '请填写正确的邮箱地址,或留空',
|
|
709
|
+
feedbackSend: '发送邮件',
|
|
710
|
+
feedbackSending: '发送中…',
|
|
711
|
+
feedbackSent: '已发送,谢谢反馈',
|
|
712
|
+
feedbackMailto: '用邮件客户端发送',
|
|
713
|
+
feedbackCopy: '复制内容',
|
|
714
|
+
feedbackCopied: '已复制到剪贴板',
|
|
715
|
+
feedbackUnconfigured: '没有可用的发信服务,可用“用邮件客户端发送”自行寄出',
|
|
716
|
+
feedbackMailtoOnly: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);现在也可以“用邮件客户端发送”',
|
|
717
|
+
feedbackNeedsTitle: '标题至少 4 个字',
|
|
718
|
+
feedbackNeedsBody: '详细描述至少 10 个字',
|
|
719
|
+
feedbackContactTooLong: '联系方式太长(≤{n} 字)',
|
|
720
|
+
feedbackFailed: '发送失败:{message}',
|
|
721
|
+
feedbackEnvNote: '自动附带:插件版本、Node/平台、发送时间',
|
|
722
|
+
feedbackSmtpShow: '设置邮件发送(SMTP)',
|
|
723
|
+
feedbackSmtpHide: '收起邮件发送设置',
|
|
724
|
+
feedbackSmtpEnvNote: '当前凭证来自环境变量(优先级高于用户层配置)',
|
|
725
|
+
feedbackSmtpHost: 'SMTP 服务器',
|
|
726
|
+
feedbackSmtpSecure: 'SSL/TLS(465 端口)',
|
|
727
|
+
feedbackSmtpPassSaved: '已保存,留空则不改动',
|
|
728
|
+
feedbackSmtpPass: '密码 / 授权码',
|
|
729
|
+
feedbackSmtpFrom: '发件邮箱(留空=同账号)',
|
|
730
|
+
feedbackSmtpHint: 'QQ、163 等邮箱要用「授权码」而不是登录密码;密码只写进本地 config.json,页面读不回来,也不会随插件发布',
|
|
731
|
+
feedbackSmtpSaveHint: '填完点「保存」(会把本页设置一起保存);不保存就点发送,密码不会被记住',
|
|
732
|
+
feedbackTooFast: '刚发过一封,请稍等半分钟',
|
|
733
|
+
feedbackTooMany: '发送过于频繁,请稍后再试',
|
|
659
734
|
},
|
|
660
735
|
en: {
|
|
661
736
|
triggerLabel: 'Ask about selection',
|
|
@@ -706,6 +781,7 @@ window.__ModuleLoader__.load({
|
|
|
706
781
|
errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
|
|
707
782
|
errorTooLarge: 'The request body is too large',
|
|
708
783
|
errorBadRequest: 'Invalid request (empty question or empty selection)',
|
|
784
|
+
errorFeedbackInvalid: 'The report does not meet the length limits — check the title and details',
|
|
709
785
|
errorUnloaded: 'The plugin is unloading and cannot take new follow-ups',
|
|
710
786
|
errorDuplicate: 'This follow-up is already being answered; wait for it to finish',
|
|
711
787
|
errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
|
|
@@ -897,6 +973,49 @@ window.__ModuleLoader__.load({
|
|
|
897
973
|
suggestionsLabel: 'Suggested follow-ups',
|
|
898
974
|
familyLegacyDiag: 'Legacy co-load',
|
|
899
975
|
familyPeers: 'Copies on page',
|
|
976
|
+
setFeedback: 'Feedback',
|
|
977
|
+
setFeedbackHint: 'Report a bug or send a suggestion by mail from here; the plugin version and runtime details are attached automatically (no chat content)',
|
|
978
|
+
feedbackOpen: 'Send feedback',
|
|
979
|
+
feedbackClose: 'Close',
|
|
980
|
+
feedbackDialogTitle: 'Send feedback',
|
|
981
|
+
feedbackKind: 'Kind',
|
|
982
|
+
feedbackKindBug: 'Bug',
|
|
983
|
+
feedbackKindIdea: 'Idea',
|
|
984
|
+
feedbackKindQuestion: 'Question',
|
|
985
|
+
feedbackKindOther: 'Other',
|
|
986
|
+
feedbackTitle: 'Title',
|
|
987
|
+
feedbackTitlePlaceholder: 'One line (4–120 characters)',
|
|
988
|
+
feedbackBody: 'Details',
|
|
989
|
+
feedbackBodyPlaceholder: 'Steps to reproduce / expected / actual (10–4000 characters)',
|
|
990
|
+
feedbackContact: 'Your email (optional, used as the reply address)',
|
|
991
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
992
|
+
feedbackContactHint: 'Filled in, a reply reaches you directly; leave it empty to stay anonymous.',
|
|
993
|
+
feedbackContactInvalid: 'Enter a valid email address, or leave it empty',
|
|
994
|
+
feedbackSend: 'Send mail',
|
|
995
|
+
feedbackSending: 'Sending…',
|
|
996
|
+
feedbackSent: 'Sent — thank you',
|
|
997
|
+
feedbackMailto: 'Send with my mail app',
|
|
998
|
+
feedbackCopy: 'Copy text',
|
|
999
|
+
feedbackCopied: 'Copied to the clipboard',
|
|
1000
|
+
feedbackUnconfigured: 'No mail service is configured; use “Send with my mail app” instead',
|
|
1001
|
+
feedbackMailtoOnly: 'The recipient is set; only the SMTP credentials (environment variables) are missing, so use “Send with my mail app”',
|
|
1002
|
+
feedbackNeedsTitle: 'The title needs at least 4 characters',
|
|
1003
|
+
feedbackNeedsBody: 'The details need at least 10 characters',
|
|
1004
|
+
feedbackContactTooLong: 'The contact line is too long (max {n} characters)',
|
|
1005
|
+
feedbackFailed: 'Send failed: {message}',
|
|
1006
|
+
feedbackEnvNote: 'Attached automatically: plugin version, Node/platform, send time',
|
|
1007
|
+
feedbackSmtpShow: 'Set up mail delivery (SMTP)',
|
|
1008
|
+
feedbackSmtpHide: 'Hide mail delivery settings',
|
|
1009
|
+
feedbackSmtpEnvNote: 'Credentials come from the environment right now (it outranks this page), so the fields below have no effect.',
|
|
1010
|
+
feedbackSmtpHost: 'SMTP server',
|
|
1011
|
+
feedbackSmtpSecure: 'SSL/TLS (port 465)',
|
|
1012
|
+
feedbackSmtpPassSaved: 'Saved — leave empty to keep it',
|
|
1013
|
+
feedbackSmtpPass: 'Password / app code',
|
|
1014
|
+
feedbackSmtpFrom: 'From address (empty = the account)',
|
|
1015
|
+
feedbackSmtpHint: 'QQ/163 need an app-specific code, not the login password. The password is written to this machine\'s config.json only; the page never reads it back and it never ships with the plugin.',
|
|
1016
|
+
feedbackSmtpSaveHint: 'Press “Save” after filling this in (it saves the whole page). Without saving, the code is not remembered and sending keeps failing.',
|
|
1017
|
+
feedbackTooFast: 'One mail just went out — wait half a minute',
|
|
1018
|
+
feedbackTooMany: 'Too many reports for now — try again later',
|
|
900
1019
|
},
|
|
901
1020
|
}
|
|
902
1021
|
|
|
@@ -967,6 +1086,12 @@ window.__ModuleLoader__.load({
|
|
|
967
1086
|
'bad-response': { key: 'errorBadResponseBody' },
|
|
968
1087
|
'stream-broken': { key: 'errorStreamBrokenBody' },
|
|
969
1088
|
'stream-ended': { key: 'errorStreamEndedBody' },
|
|
1089
|
+
// Feedback endpoints: these four are fully known Client-side, so an
|
|
1090
|
+
// English UI never shows the Host's Chinese line for them.
|
|
1091
|
+
'feedback-invalid': { key: 'errorFeedbackInvalid' },
|
|
1092
|
+
'feedback-unconfigured': { key: 'feedbackUnconfigured' },
|
|
1093
|
+
'feedback-too-fast': { key: 'feedbackTooFast' },
|
|
1094
|
+
'feedback-too-many': { key: 'feedbackTooMany' },
|
|
970
1095
|
}
|
|
971
1096
|
|
|
972
1097
|
/** One localized line for a wire error; unknown codes keep the Host text. */
|
|
@@ -1639,6 +1764,54 @@ window.__ModuleLoader__.load({
|
|
|
1639
1764
|
}
|
|
1640
1765
|
}
|
|
1641
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Validate a feedback draft BEFORE posting it, so the Host's 400 never
|
|
1769
|
+
* comes back as a surprise. The bounds are the live Host ones (see
|
|
1770
|
+
* {@link FEEDBACK_DEFAULTS}); the CR/LF rule mirrors the Host's own
|
|
1771
|
+
* header-injection fence.
|
|
1772
|
+
* @param {{kind?: string, title?: string, body?: string, contact?: string}} draft
|
|
1773
|
+
* @param {{titleMin: number, titleMax: number, bodyMin: number, bodyMax: number, contactMax?: number}} [limits]
|
|
1774
|
+
* @returns {{ok: true}|{ok: false, key: string}}
|
|
1775
|
+
*/
|
|
1776
|
+
function validateFeedbackDraft(draft, limits) {
|
|
1777
|
+
const bound = limits ?? FEEDBACK_DEFAULTS
|
|
1778
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1779
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1780
|
+
const chars = text => Array.from(text).length
|
|
1781
|
+
if (title.includes('\n') || title.includes('\r')) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1782
|
+
if (chars(title) < bound.titleMin || chars(title) > bound.titleMax) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1783
|
+
if (chars(body) < bound.bodyMin || chars(body) > bound.bodyMax) return { ok: false, key: 'feedbackNeedsBody' }
|
|
1784
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1785
|
+
if (bound.contactMax !== undefined && chars(contact) > bound.contactMax) return { ok: false, key: 'feedbackContactTooLong' }
|
|
1786
|
+
// Same pragmatic mailbox rule as the Host: the value becomes `Reply-To:`,
|
|
1787
|
+
// so anything that is not an address would be rejected by the relay.
|
|
1788
|
+
if (contact !== '' && !FEEDBACK_EMAIL_PATTERN.test(contact)) return { ok: false, key: 'feedbackContactInvalid' }
|
|
1789
|
+
return { ok: true }
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** One-line subject for the report (also used by the `mailto:` fallback). */
|
|
1793
|
+
function feedbackSubjectFor({ version, draft }) {
|
|
1794
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1795
|
+
const kind = typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'
|
|
1796
|
+
return `[dsh-sidecard-ask ${version}] ${kind}: ${title === '' ? 'feedback' : title}`
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** The report as plain text: what the copy button puts on the clipboard. */
|
|
1800
|
+
function feedbackPlainTextFor({ version, draft, userAgent }) {
|
|
1801
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1802
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1803
|
+
return [
|
|
1804
|
+
`kind: ${typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'}`,
|
|
1805
|
+
`title: ${typeof draft?.title === 'string' ? draft.title.trim() : ''}`,
|
|
1806
|
+
`contact: ${contact === '' ? '(not provided)' : contact}`,
|
|
1807
|
+
'',
|
|
1808
|
+
body,
|
|
1809
|
+
'',
|
|
1810
|
+
`plugin: ${version}`,
|
|
1811
|
+
`user agent: ${typeof userAgent === 'string' ? userAgent : ''}`,
|
|
1812
|
+
].join('\n')
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1642
1815
|
/**
|
|
1643
1816
|
* Validate a settings-page quick-action draft BEFORE posting it, so the
|
|
1644
1817
|
* Host's all-or-nothing refusal never comes back as a surprise. Also
|
|
@@ -2052,12 +2225,28 @@ window.__ModuleLoader__.load({
|
|
|
2052
2225
|
font-family:inherit;white-space:nowrap}
|
|
2053
2226
|
.dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
2054
2227
|
.dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
2055
|
-
/* The one-click action bar: the
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
.
|
|
2060
|
-
|
|
2228
|
+
/* The one-click action bar: the main ask button plus the quick chips, inside ONE
|
|
2229
|
+
rounded surface. Six bordered pills in a row read as six competing actions —
|
|
2230
|
+
no primary, no grouping — so the bar is a single control: a filled brand
|
|
2231
|
+
button for "ask", a hairline separator, then quiet ghost chips. It wraps
|
|
2232
|
+
instead of clipping (the component also caps its width to the viewport). */
|
|
2233
|
+
.dsa-quickbar{pointer-events:auto;position:fixed;display:inline-flex;align-items:center;
|
|
2234
|
+
gap:2px;row-gap:2px;padding:3px;border-radius:12px;flex-wrap:wrap;z-index:60;
|
|
2235
|
+
border:1px solid rgba(127,127,127,.5);
|
|
2236
|
+
background:var(--dsw-alias-bg-overlay, #fff);
|
|
2237
|
+
box-shadow:0 6px 20px rgba(0,0,0,.16), 0 1px 3px rgba(0,0,0,.10)}
|
|
2238
|
+
.dsa-quickbar .dsa-trigger-embedded{position:static;border:0;border-radius:9px;
|
|
2239
|
+
padding:4px 10px;font-weight:600;box-shadow:none;
|
|
2240
|
+
background:var(--dsw-alias-brand-primary, #4c8dff);color:#fff}
|
|
2241
|
+
.dsa-quickbar .dsa-trigger-embedded:hover{border-color:transparent;filter:brightness(1.06)}
|
|
2242
|
+
/* Dark brand (#5b9cf5 on the bundled skin) puts white at 2.79:1 — below the 4.5:1
|
|
2243
|
+
body-text floor. Dark ink measures 6.57:1, same remedy as the carrier switch. */
|
|
2244
|
+
html[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded,
|
|
2245
|
+
body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
|
|
2246
|
+
/* A hairline between "do the main thing" and "or pick a canned question". */
|
|
2247
|
+
.dsa-quickbar-sep{flex:none;align-self:center;width:1px;height:15px;margin:0 2px;
|
|
2248
|
+
background:var(--dsw-alias-border-l1, rgba(127,127,127,.28))}
|
|
2249
|
+
.dsa-quickbar-chips{display:contents}
|
|
2061
2250
|
.dsa-chip{pointer-events:auto;padding:3px 10px;border-radius:999px;
|
|
2062
2251
|
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
2063
2252
|
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
@@ -2065,6 +2254,14 @@ window.__ModuleLoader__.load({
|
|
|
2065
2254
|
box-shadow:0 2px 8px rgba(0,0,0,.14)}
|
|
2066
2255
|
.dsa-chip:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
2067
2256
|
.dsa-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
2257
|
+
/* Inside the action bar the chips lose their own frame: the bar is the frame.
|
|
2258
|
+
Secondary text colour keeps them quieter than the filled ask button while
|
|
2259
|
+
still clearing 6:1 on the bundled skin (label-secondary 9.36:1 in dark). */
|
|
2260
|
+
.dsa-quickbar .dsa-chip{border:0;background:transparent;box-shadow:none;
|
|
2261
|
+
padding:3px 8px;border-radius:7px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2262
|
+
.dsa-quickbar .dsa-chip:hover{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));
|
|
2263
|
+
color:var(--dsw-alias-label-primary, #111)}
|
|
2264
|
+
.dsa-quickbar .dsa-chip:focus-visible{outline-offset:0}
|
|
2068
2265
|
/* Chips rendered INSIDE the popover need no floating shadow. */
|
|
2069
2266
|
.dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
2070
2267
|
.dsa-pop-chips .dsa-chip{box-shadow:none}
|
|
@@ -2131,6 +2328,11 @@ window.__ModuleLoader__.load({
|
|
|
2131
2328
|
.dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
2132
2329
|
.dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
|
|
2133
2330
|
color:#fff;font-weight:500}
|
|
2331
|
+
/* Same reason as the segmented chips and the quick-bar primary: the uiskin dark
|
|
2332
|
+
brand is a light blue (#5b9cf5) where white text measures 2.79:1. Dark ink
|
|
2333
|
+
lands at 6.57:1 and keeps the button reading as "filled". */
|
|
2334
|
+
html[data-ds-dark-theme] .dsa-btn-primary,
|
|
2335
|
+
body[data-ds-dark-theme] .dsa-btn-primary{color:#0a1428}
|
|
2134
2336
|
.dsa-btn:disabled{opacity:.55;cursor:default}
|
|
2135
2337
|
/* Icon-only variant (the floating layer's collapse/hide controls): square and
|
|
2136
2338
|
quiet so the card title bar stays one line and the title keeps the weight. */
|
|
@@ -2291,6 +2493,42 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2291
2493
|
.dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
|
|
2292
2494
|
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
2293
2495
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
2496
|
+
/* Feedback (settings page), v1.9.1 rebuild: one compact banner at the very top
|
|
2497
|
+
of the page, and a centered dialog for the form. The form used to be an
|
|
2498
|
+
always-visible inline panel here, which pushed every real setting below the
|
|
2499
|
+
fold; the SMTP transport form is gone from the page entirely — the Host reads
|
|
2500
|
+
it from the environment / user config, so there is nothing for an end user to
|
|
2501
|
+
fill in. The dialog is position:fixed with a high z-index because this
|
|
2502
|
+
section is rendered INSIDE the app's settings modal. */
|
|
2503
|
+
.dsa-feedback-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 14px;
|
|
2504
|
+
border-radius:12px;border:1px solid var(--dsw-alias-brand-primary, #4c8dff);
|
|
2505
|
+
background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))}
|
|
2506
|
+
.dsa-feedback-bar-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 240px}
|
|
2507
|
+
.dsa-feedback-bar-title{font-weight:600;color:var(--dsw-alias-label-primary, #111)}
|
|
2508
|
+
.dsa-feedback-bar-hint{font-size:12px;line-height:1.5;
|
|
2509
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2510
|
+
.dsa-feedback-bar > .dsa-btn{flex:0 0 auto;margin-left:auto}
|
|
2511
|
+
.dsa-feedback-backdrop{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;
|
|
2512
|
+
justify-content:center;padding:24px;background:rgba(0,0,0,.45)}
|
|
2513
|
+
.dsa-feedback-dialog{display:flex;flex-direction:column;gap:10px;width:min(560px, 100%);
|
|
2514
|
+
max-height:calc(100vh - 48px);overflow:auto;padding:16px 18px;border-radius:14px;
|
|
2515
|
+
border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
2516
|
+
background:var(--dsw-alias-bg-overlay, rgba(127,127,127,.03));color:inherit;
|
|
2517
|
+
box-shadow:0 18px 48px rgba(0,0,0,.28)}
|
|
2518
|
+
.dsa-feedback-dialog-head{display:flex;align-items:center;gap:8px}
|
|
2519
|
+
.dsa-feedback-dialog-head > strong{flex:1;min-width:0;font-size:14px}
|
|
2520
|
+
.dsa-feedback-dialog-head > .dsa-feedback-close{flex:0 0 auto;width:28px;height:28px;
|
|
2521
|
+
padding:0;line-height:1;font-size:16px}
|
|
2522
|
+
.dsa-feedback-dialog-field{display:flex;flex-direction:column;gap:4px}
|
|
2523
|
+
.dsa-feedback-dialog-field > label{font-size:12px;
|
|
2524
|
+
color:var(--dsw-alias-label-secondary, #555)}
|
|
2525
|
+
.dsa-feedback-dialog-field > textarea{min-height:120px;resize:vertical}
|
|
2526
|
+
.dsa-feedback-dialog-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
2527
|
+
.dsa-feedback-dialog-hint{font-size:12px;line-height:1.5;
|
|
2528
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2529
|
+
.dsa-feedback-state{font-size:12px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2530
|
+
.dsa-feedback-state[data-state="sent"]{color:var(--dsw-alias-state-success-primary, #1a7f37)}
|
|
2531
|
+
.dsa-feedback-state[data-state="error"]{color:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
2294
2532
|
/* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
|
|
2295
2533
|
dsh-selection-followup ≤1.0.x build. When this build is the elected
|
|
2296
2534
|
leader it hides the old build's whole interaction surface so exactly
|
|
@@ -3440,12 +3678,23 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
3440
3678
|
(sum, action) => sum + Math.ceil(estimateLabelWidth(action.label) + 24), 0)
|
|
3441
3679
|
const rowWidth = Math.min(620, mainWidth + (actions.length > 0 ? 6 + chipsWidth : 0))
|
|
3442
3680
|
const box = clampBox(candidate.rect, rowWidth, 34, 'below')
|
|
3681
|
+
// The estimate above is a clamp input, not a promise: if it is wrong the
|
|
3682
|
+
// bar must wrap, never spill past the frame edge. Cap its width to the
|
|
3683
|
+
// space actually left of it (and still to 620px, which is about as wide
|
|
3684
|
+
// as an action bar stays readable).
|
|
3685
|
+
const viewportWidth = (typeof window !== 'undefined' && Number.isFinite(window.innerWidth))
|
|
3686
|
+
? window.innerWidth
|
|
3687
|
+
: 0
|
|
3688
|
+
const room = viewportWidth > 0 ? Math.max(220, viewportWidth - box.left - 8) : 620
|
|
3443
3689
|
return h('div', {
|
|
3444
3690
|
className: 'dsa-quickbar',
|
|
3445
|
-
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
3691
|
+
style: { left: `${box.left}px`, top: `${box.top}px`, maxWidth: `${Math.min(620, room)}px` },
|
|
3446
3692
|
onMouseDown: event => event.stopPropagation(),
|
|
3447
3693
|
},
|
|
3448
3694
|
h(TriggerButton, { candidate, onOpen, embedded: true }),
|
|
3695
|
+
actions.length > 0
|
|
3696
|
+
? h('span', { key: 'sep', className: 'dsa-quickbar-sep', 'aria-hidden': true })
|
|
3697
|
+
: null,
|
|
3449
3698
|
h(QuickChips, { actions, onPick: onQuick, inline: true }))
|
|
3450
3699
|
}
|
|
3451
3700
|
|
|
@@ -4293,6 +4542,142 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4293
4542
|
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
4294
4543
|
}
|
|
4295
4544
|
|
|
4545
|
+
/**
|
|
4546
|
+
* The feedback dialog (`role="dialog"`).
|
|
4547
|
+
*
|
|
4548
|
+
* Module scope on purpose: the settings page only mounts it while open, and
|
|
4549
|
+
* the contract test renders it directly — the test React shim has no working
|
|
4550
|
+
* setState, so a click-driven open cannot be exercised through the page.
|
|
4551
|
+
* Everything it needs arrives through props, so it is also the one surface
|
|
4552
|
+
* that can be checked without a Host.
|
|
4553
|
+
*/
|
|
4554
|
+
function FeedbackDialog(props) {
|
|
4555
|
+
const {
|
|
4556
|
+
t, limits, draft, onChange, status, error, ready, reason, hasRecipient,
|
|
4557
|
+
onClose, onSubmit, onMailto, onCopy,
|
|
4558
|
+
} = props
|
|
4559
|
+
const sending = status === 'sending'
|
|
4560
|
+
// Escape closes it: a modal without a keyboard exit is a trap.
|
|
4561
|
+
React.useEffect(() => {
|
|
4562
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
|
|
4563
|
+
return undefined
|
|
4564
|
+
}
|
|
4565
|
+
const onKey = event => { if (event !== null && event?.key === 'Escape') onClose() }
|
|
4566
|
+
document.addEventListener('keydown', onKey)
|
|
4567
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
4568
|
+
}, [onClose])
|
|
4569
|
+
|
|
4570
|
+
const field = (label, control) => h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4571
|
+
h('label', null, label),
|
|
4572
|
+
control)
|
|
4573
|
+
|
|
4574
|
+
return h('div', {
|
|
4575
|
+
className: 'dsa-feedback-backdrop',
|
|
4576
|
+
'data-dsa-feedback-dialog': '',
|
|
4577
|
+
onClick: event => {
|
|
4578
|
+
// Only a click on the backdrop itself closes; clicks inside the card
|
|
4579
|
+
// bubble here and must not dismiss a half-written report.
|
|
4580
|
+
if (event !== null && event !== undefined && event.target === event.currentTarget) onClose()
|
|
4581
|
+
},
|
|
4582
|
+
},
|
|
4583
|
+
h('div', {
|
|
4584
|
+
className: 'dsa-feedback-dialog',
|
|
4585
|
+
role: 'dialog',
|
|
4586
|
+
'aria-modal': 'true',
|
|
4587
|
+
'aria-label': t('feedbackDialogTitle'),
|
|
4588
|
+
},
|
|
4589
|
+
h('div', { className: 'dsa-feedback-dialog-head' },
|
|
4590
|
+
h('strong', null, t('feedbackDialogTitle')),
|
|
4591
|
+
h('button', {
|
|
4592
|
+
type: 'button',
|
|
4593
|
+
className: 'dsa-btn dsa-feedback-close',
|
|
4594
|
+
'data-feedback-close': '',
|
|
4595
|
+
title: t('feedbackClose'),
|
|
4596
|
+
'aria-label': t('feedbackClose'),
|
|
4597
|
+
onClick: onClose,
|
|
4598
|
+
}, '×')),
|
|
4599
|
+
h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4600
|
+
h('label', null, t('feedbackKind')),
|
|
4601
|
+
h('select', {
|
|
4602
|
+
className: 'dsa-input',
|
|
4603
|
+
'data-feedback-field': 'kind',
|
|
4604
|
+
value: draft.kind,
|
|
4605
|
+
onChange: event => onChange({ kind: event.target.value }),
|
|
4606
|
+
}, [
|
|
4607
|
+
['bug', t('feedbackKindBug')],
|
|
4608
|
+
['idea', t('feedbackKindIdea')],
|
|
4609
|
+
['question', t('feedbackKindQuestion')],
|
|
4610
|
+
['other', t('feedbackKindOther')],
|
|
4611
|
+
].map(([value, label]) => h('option', { key: value, value }, label)))),
|
|
4612
|
+
field(t('feedbackTitle'), h('input', {
|
|
4613
|
+
className: 'dsa-input',
|
|
4614
|
+
'data-feedback-field': 'title',
|
|
4615
|
+
placeholder: t('feedbackTitlePlaceholder'),
|
|
4616
|
+
maxLength: limits.titleMax,
|
|
4617
|
+
value: draft.title,
|
|
4618
|
+
onChange: event => onChange({ title: event.target.value }),
|
|
4619
|
+
})),
|
|
4620
|
+
field(t('feedbackBody'), h('textarea', {
|
|
4621
|
+
className: 'dsa-textarea',
|
|
4622
|
+
'data-feedback-field': 'body',
|
|
4623
|
+
placeholder: t('feedbackBodyPlaceholder'),
|
|
4624
|
+
maxLength: limits.bodyMax,
|
|
4625
|
+
value: draft.body,
|
|
4626
|
+
onChange: event => onChange({ body: event.target.value }),
|
|
4627
|
+
})),
|
|
4628
|
+
// The reporter's own address becomes the mail's `Reply-To:`, never its
|
|
4629
|
+
// `From:` — the relay authenticates as the owner's account and would
|
|
4630
|
+
// reject a foreign sender.
|
|
4631
|
+
field(t('feedbackContact'), h('input', {
|
|
4632
|
+
className: 'dsa-input',
|
|
4633
|
+
'data-feedback-field': 'contact',
|
|
4634
|
+
type: 'email',
|
|
4635
|
+
inputMode: 'email',
|
|
4636
|
+
autoComplete: 'email',
|
|
4637
|
+
placeholder: t('feedbackContactPlaceholder'),
|
|
4638
|
+
maxLength: limits.contactMax,
|
|
4639
|
+
value: draft.contact,
|
|
4640
|
+
onChange: event => onChange({ contact: event.target.value }),
|
|
4641
|
+
})),
|
|
4642
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackContactHint')),
|
|
4643
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackEnvNote')),
|
|
4644
|
+
// Readiness: three states, not two. No recipient at all, recipient but no
|
|
4645
|
+
// usable transport (the mail-app path still works), fully ready. When the
|
|
4646
|
+
// Host explains why, print its sentence verbatim.
|
|
4647
|
+
ready === true
|
|
4648
|
+
? null
|
|
4649
|
+
: h('span', { className: 'dsa-feedback-dialog-hint', 'data-feedback-readiness': 'unready' },
|
|
4650
|
+
String(reason ?? '') !== ''
|
|
4651
|
+
? String(reason)
|
|
4652
|
+
: (hasRecipient === true ? t('feedbackMailtoOnly') : t('feedbackUnconfigured'))),
|
|
4653
|
+
h('div', { className: 'dsa-feedback-dialog-actions' },
|
|
4654
|
+
h('button', {
|
|
4655
|
+
type: 'button',
|
|
4656
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
4657
|
+
'data-feedback-submit': '',
|
|
4658
|
+
disabled: sending,
|
|
4659
|
+
title: ready === true ? undefined : t('feedbackUnconfigured'),
|
|
4660
|
+
onClick: onSubmit,
|
|
4661
|
+
}, sending ? t('feedbackSending') : t('feedbackSend')),
|
|
4662
|
+
h('button', {
|
|
4663
|
+
type: 'button',
|
|
4664
|
+
className: 'dsa-btn',
|
|
4665
|
+
'data-feedback-mailto': '',
|
|
4666
|
+
onClick: onMailto,
|
|
4667
|
+
}, t('feedbackMailto')),
|
|
4668
|
+
h('button', {
|
|
4669
|
+
type: 'button',
|
|
4670
|
+
className: 'dsa-btn',
|
|
4671
|
+
'data-feedback-copy': '',
|
|
4672
|
+
onClick: onCopy,
|
|
4673
|
+
}, t('feedbackCopy'))),
|
|
4674
|
+
status === 'sent'
|
|
4675
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'sent', role: 'status' }, t('feedbackSent'))
|
|
4676
|
+
: status === 'error'
|
|
4677
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'error', role: 'status' }, error)
|
|
4678
|
+
: null))
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4296
4681
|
/** The settings page (`settings.section`). */
|
|
4297
4682
|
function SettingsSection({ actions }) {
|
|
4298
4683
|
const state = useStoreState()
|
|
@@ -4300,6 +4685,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4300
4685
|
const [status, setStatus] = React.useState('idle')
|
|
4301
4686
|
const [diag, setDiag] = React.useState(null)
|
|
4302
4687
|
const [historyQuery, setHistoryQuery] = React.useState('')
|
|
4688
|
+
// ── feedback form ──────────────────────────────────────────────────
|
|
4689
|
+
// `idle` → typing; `sending` → one POST in flight (button locked);
|
|
4690
|
+
// `sent` → the draft was cleared; `error` → the draft is KEPT, so a
|
|
4691
|
+
// relay failure never eats what the user just wrote.
|
|
4692
|
+
// Closed by default: the entry must be the first thing on the page and
|
|
4693
|
+
// one click away, but an always-open form pushed every real setting below
|
|
4694
|
+
// the fold (the previous version did exactly that and read as clutter).
|
|
4695
|
+
const [feedbackOpen, setFeedbackOpen] = React.useState(false)
|
|
4696
|
+
const [feedbackDraft, setFeedbackDraft] = React.useState({ kind: 'bug', title: '', body: '', contact: '' })
|
|
4697
|
+
const [feedbackStatus, setFeedbackStatus] = React.useState('idle')
|
|
4698
|
+
const [feedbackError, setFeedbackError] = React.useState('')
|
|
4699
|
+
// Editing is write-only: the saved address is never sent back to this page
|
|
4700
|
+
// (the Host strips it from `/state`), so the form can only replace it.
|
|
4701
|
+
const feedbackInfo = state.capabilities?.feedback ?? null
|
|
4702
|
+
const feedbackSmtp = feedbackInfo?.smtp ?? null
|
|
4703
|
+
const feedbackLimits = feedbackInfo?.limits ?? {
|
|
4704
|
+
titleMin: 4, titleMax: 120, bodyMin: 10, bodyMax: 4000, contactMax: 120,
|
|
4705
|
+
}
|
|
4303
4706
|
const [notifyPerm, setNotifyPerm] = React.useState(
|
|
4304
4707
|
typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
|
|
4305
4708
|
const config = state.config
|
|
@@ -4327,6 +4730,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4327
4730
|
control,
|
|
4328
4731
|
hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
|
|
4329
4732
|
|
|
4733
|
+
// ── feedback actions ───────────────────────────────────────────────
|
|
4734
|
+
const feedbackUserAgent = typeof navigator === 'undefined' ? '' : String(navigator.userAgent ?? '')
|
|
4735
|
+
|
|
4736
|
+
const submitFeedback = async () => {
|
|
4737
|
+
if (feedbackStatus === 'sending') return
|
|
4738
|
+
const verdict = validateFeedbackDraft(feedbackDraft, feedbackLimits)
|
|
4739
|
+
if (verdict.ok !== true) {
|
|
4740
|
+
setFeedbackStatus('error')
|
|
4741
|
+
setFeedbackError(t(verdict.key, { n: feedbackLimits.contactMax ?? FEEDBACK_DEFAULTS.contactMax }))
|
|
4742
|
+
return
|
|
4743
|
+
}
|
|
4744
|
+
setFeedbackStatus('sending')
|
|
4745
|
+
setFeedbackError('')
|
|
4746
|
+
try {
|
|
4747
|
+
await postJson('feedback', {
|
|
4748
|
+
kind: feedbackDraft.kind,
|
|
4749
|
+
title: feedbackDraft.title.trim(),
|
|
4750
|
+
body: feedbackDraft.body.trim(),
|
|
4751
|
+
contact: feedbackDraft.contact.trim(),
|
|
4752
|
+
})
|
|
4753
|
+
setFeedbackStatus('sent')
|
|
4754
|
+
setFeedbackDraft({ kind: feedbackDraft.kind, title: '', body: '', contact: '' })
|
|
4755
|
+
} catch (error) {
|
|
4756
|
+
setFeedbackStatus('error')
|
|
4757
|
+
setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
/**
|
|
4762
|
+
* Fallback that needs no credentials: hand the draft to the mail app.
|
|
4763
|
+
*
|
|
4764
|
+
* The URL is built by the Host — deliberately, so the plaintext recipient
|
|
4765
|
+
* is never held or rendered by this page. If the Host has no recipient,
|
|
4766
|
+
* it answers 503 and the failure is shown like any other.
|
|
4767
|
+
*/
|
|
4768
|
+
const sendFeedbackByMailApp = async () => {
|
|
4769
|
+
try {
|
|
4770
|
+
const value = await postJson('feedback-mailto', {
|
|
4771
|
+
kind: feedbackDraft.kind,
|
|
4772
|
+
title: feedbackDraft.title.trim(),
|
|
4773
|
+
body: feedbackDraft.body.trim(),
|
|
4774
|
+
contact: feedbackDraft.contact.trim(),
|
|
4775
|
+
userAgent: feedbackUserAgent,
|
|
4776
|
+
})
|
|
4777
|
+
const url = typeof value?.url === 'string' ? value.url : ''
|
|
4778
|
+
if (url === '') throw new Error('no mailto url')
|
|
4779
|
+
if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(url, '_self')
|
|
4780
|
+
else if (typeof location !== 'undefined') location.href = url
|
|
4781
|
+
} catch (error) {
|
|
4782
|
+
setFeedbackStatus('error')
|
|
4783
|
+
setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
const copyFeedbackDraft = async () => {
|
|
4788
|
+
const text = feedbackPlainTextFor({
|
|
4789
|
+
version: state.version ?? CLIENT_VERSION,
|
|
4790
|
+
draft: feedbackDraft,
|
|
4791
|
+
userAgent: feedbackUserAgent,
|
|
4792
|
+
})
|
|
4793
|
+
const result = await copyText(text, null)
|
|
4794
|
+
toast(result === 'copied' ? t('feedbackCopied') : t('copyFailed'))
|
|
4795
|
+
}
|
|
4796
|
+
|
|
4330
4797
|
const select = (key, options) => h('select', {
|
|
4331
4798
|
className: 'dsa-input',
|
|
4332
4799
|
value: String(draft[key]),
|
|
@@ -4553,6 +5020,45 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4553
5020
|
? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
|
|
4554
5021
|
: null)
|
|
4555
5022
|
|
|
5023
|
+
// ── feedback entry ─────────────────────────────────────────────────
|
|
5024
|
+
// One compact banner at the very top of the page, and a dialog for the
|
|
5025
|
+
// form. Nothing else about feedback lives on this page: the transport is
|
|
5026
|
+
// the Host's business (environment / user config), so an end user has
|
|
5027
|
+
// nothing to fill in before writing a report. The banner also works when
|
|
5028
|
+
// no SMTP endpoint is configured — the `mailto:` path needs nothing but a
|
|
5029
|
+
// recipient — so the dialog says that in one line instead of looking broken.
|
|
5030
|
+
const feedbackConfigured = feedbackInfo !== null && feedbackInfo.configured === true
|
|
5031
|
+
const feedbackBanner = h('div', { className: 'dsa-feedback-bar', 'data-dsa-feedback-section': '' },
|
|
5032
|
+
h('div', { className: 'dsa-feedback-bar-text' },
|
|
5033
|
+
h('div', { className: 'dsa-feedback-bar-title' }, t('setFeedback')),
|
|
5034
|
+
h('div', { className: 'dsa-feedback-bar-hint' }, t('setFeedbackHint'))),
|
|
5035
|
+
h('button', {
|
|
5036
|
+
type: 'button',
|
|
5037
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
5038
|
+
'data-feedback-open': '',
|
|
5039
|
+
'aria-haspopup': 'dialog',
|
|
5040
|
+
'aria-expanded': feedbackOpen ? 'true' : 'false',
|
|
5041
|
+
onClick: () => setFeedbackOpen(true),
|
|
5042
|
+
}, t('feedbackOpen')))
|
|
5043
|
+
|
|
5044
|
+
const feedbackDialog = feedbackOpen !== true
|
|
5045
|
+
? null
|
|
5046
|
+
: h(FeedbackDialog, {
|
|
5047
|
+
t,
|
|
5048
|
+
limits: feedbackLimits,
|
|
5049
|
+
draft: feedbackDraft,
|
|
5050
|
+
onChange: patch => setFeedbackDraft({ ...feedbackDraft, ...patch }),
|
|
5051
|
+
status: feedbackStatus,
|
|
5052
|
+
error: feedbackError,
|
|
5053
|
+
ready: feedbackConfigured,
|
|
5054
|
+
reason: String(feedbackInfo?.reason ?? ''),
|
|
5055
|
+
hasRecipient: feedbackInfo?.hasRecipient === true,
|
|
5056
|
+
onClose: () => setFeedbackOpen(false),
|
|
5057
|
+
onSubmit: () => { void submitFeedback() },
|
|
5058
|
+
onMailto: sendFeedbackByMailApp,
|
|
5059
|
+
onCopy: () => { void copyFeedbackDraft() },
|
|
5060
|
+
})
|
|
5061
|
+
|
|
4556
5062
|
// NOT marked `data-dsa-rail-host`: the settings page is not a rail page,
|
|
4557
5063
|
// and marking it would let the floating layer dock over the settings panel.
|
|
4558
5064
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
@@ -4560,6 +5066,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4560
5066
|
h('div', null,
|
|
4561
5067
|
h('div', { className: 'dsa-title' }, t('settingsTitle')),
|
|
4562
5068
|
h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
|
|
5069
|
+
feedbackBanner,
|
|
4563
5070
|
familyBanner,
|
|
4564
5071
|
h('div', { className: 'dsa-grid' },
|
|
4565
5072
|
field(t('setTrigger'), select('trigger', [
|
|
@@ -4718,7 +5225,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4718
5225
|
h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSurfaces()}`),
|
|
4719
5226
|
h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
|
|
4720
5227
|
])
|
|
4721
|
-
: null
|
|
5228
|
+
: null,
|
|
5229
|
+
feedbackDialog)
|
|
4722
5230
|
}
|
|
4723
5231
|
|
|
4724
5232
|
/** Session probe: an invisible entry in the composer tool row. */
|
|
@@ -5456,6 +5964,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5456
5964
|
problems: value.problems ?? [],
|
|
5457
5965
|
provenance: value.provenance ?? null,
|
|
5458
5966
|
})
|
|
5967
|
+
// Readiness hints (the feedback card's "缺的只是发信凭据", the surface
|
|
5968
|
+
// list, → live in `capabilities`, which only `/state` reports. Without
|
|
5969
|
+
// this refresh the page kept telling the user the transport was
|
|
5970
|
+
// missing right after they had saved it.
|
|
5971
|
+
try {
|
|
5972
|
+
await refreshState()
|
|
5973
|
+
} catch {
|
|
5974
|
+
// The save itself succeeded; a failed re-read must not undo that.
|
|
5975
|
+
}
|
|
5459
5976
|
return value
|
|
5460
5977
|
},
|
|
5461
5978
|
async resetConfig() {
|
|
@@ -6236,6 +6753,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6236
6753
|
}, 'sidecard-ask: client activation')
|
|
6237
6754
|
},
|
|
6238
6755
|
|
|
6756
|
+
/**
|
|
6757
|
+
* Test seam: components that can be rendered on their own. The feedback
|
|
6758
|
+
* dialog lives here because the settings page mounts it only while open and
|
|
6759
|
+
* the test shim has no working setState, so a simulated click cannot reach
|
|
6760
|
+
* it.
|
|
6761
|
+
*/
|
|
6762
|
+
components: Object.freeze({ FeedbackDialog }),
|
|
6763
|
+
|
|
6239
6764
|
/**
|
|
6240
6765
|
* Test seam: pure helpers, frozen. The DSH client runtime only reads
|
|
6241
6766
|
* `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
|
|
@@ -6259,6 +6784,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6259
6784
|
quickActionsOf,
|
|
6260
6785
|
renderActionQuestion,
|
|
6261
6786
|
validateQuickActionsDraft,
|
|
6787
|
+
validateFeedbackDraft,
|
|
6788
|
+
feedbackSubjectFor,
|
|
6789
|
+
feedbackPlainTextFor,
|
|
6790
|
+
FEEDBACK_DEFAULTS,
|
|
6262
6791
|
shortcutFingerprint,
|
|
6263
6792
|
followUpChips,
|
|
6264
6793
|
formatElapsed,
|