dsh-sidecard-ask 1.8.4 → 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.4'
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
@@ -2155,6 +2328,11 @@ body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
2155
2328
  .dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
2156
2329
  .dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
2157
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}
2158
2336
  .dsa-btn:disabled{opacity:.55;cursor:default}
2159
2337
  /* Icon-only variant (the floating layer's collapse/hide controls): square and
2160
2338
  quiet so the card title bar stays one line and the title keeps the weight. */
@@ -2315,6 +2493,42 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2315
2493
  .dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
2316
2494
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
2317
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)}
2318
2532
  /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
2319
2533
  dsh-selection-followup ≤1.0.x build. When this build is the elected
2320
2534
  leader it hides the old build's whole interaction surface so exactly
@@ -4328,6 +4542,142 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4328
4542
  state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
4329
4543
  }
4330
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
+
4331
4681
  /** The settings page (`settings.section`). */
4332
4682
  function SettingsSection({ actions }) {
4333
4683
  const state = useStoreState()
@@ -4335,6 +4685,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4335
4685
  const [status, setStatus] = React.useState('idle')
4336
4686
  const [diag, setDiag] = React.useState(null)
4337
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
+ }
4338
4706
  const [notifyPerm, setNotifyPerm] = React.useState(
4339
4707
  typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
4340
4708
  const config = state.config
@@ -4362,6 +4730,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4362
4730
  control,
4363
4731
  hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
4364
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
+
4365
4797
  const select = (key, options) => h('select', {
4366
4798
  className: 'dsa-input',
4367
4799
  value: String(draft[key]),
@@ -4588,6 +5020,45 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4588
5020
  ? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
4589
5021
  : null)
4590
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
+
4591
5062
  // NOT marked `data-dsa-rail-host`: the settings page is not a rail page,
4592
5063
  // and marking it would let the floating layer dock over the settings panel.
4593
5064
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
@@ -4595,6 +5066,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4595
5066
  h('div', null,
4596
5067
  h('div', { className: 'dsa-title' }, t('settingsTitle')),
4597
5068
  h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
5069
+ feedbackBanner,
4598
5070
  familyBanner,
4599
5071
  h('div', { className: 'dsa-grid' },
4600
5072
  field(t('setTrigger'), select('trigger', [
@@ -4753,7 +5225,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4753
5225
  h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSurfaces()}`),
4754
5226
  h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
4755
5227
  ])
4756
- : null)
5228
+ : null,
5229
+ feedbackDialog)
4757
5230
  }
4758
5231
 
4759
5232
  /** Session probe: an invisible entry in the composer tool row. */
@@ -5491,6 +5964,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5491
5964
  problems: value.problems ?? [],
5492
5965
  provenance: value.provenance ?? null,
5493
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
+ }
5494
5976
  return value
5495
5977
  },
5496
5978
  async resetConfig() {
@@ -6271,6 +6753,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6271
6753
  }, 'sidecard-ask: client activation')
6272
6754
  },
6273
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
+
6274
6764
  /**
6275
6765
  * Test seam: pure helpers, frozen. The DSH client runtime only reads
6276
6766
  * `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
@@ -6294,6 +6784,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6294
6784
  quickActionsOf,
6295
6785
  renderActionQuestion,
6296
6786
  validateQuickActionsDraft,
6787
+ validateFeedbackDraft,
6788
+ feedbackSubjectFor,
6789
+ feedbackPlainTextFor,
6790
+ FEEDBACK_DEFAULTS,
6297
6791
  shortcutFingerprint,
6298
6792
  followUpChips,
6299
6793
  formatElapsed,