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/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.8.3'
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 trigger button plus compact chips, laid out
2056
- in one wrapping row under the selection. The container is positioned by the
2057
- component; chips keep their own pointer-events because .dsa-layer is a
2058
- click-through layer. */
2059
- .dsa-quickbar{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:6px;
2060
- max-width:min(620px, 92vw);flex-wrap:wrap;z-index:60}
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,