dsh-sidecard-ask 1.8.4 → 2.0.0

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