dsh-sidecard-ask 1.9.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.1
4
+
5
+ **主题:清掉 6 个死 i18n 键。无行为变化,无 API / 配置变化,升级无需任何操作。**
6
+
7
+ - **删除 4 个纯死字符串**:`selectionEmpty`、`selectionTooShort`、`floatDocked`、`resetDone`。它们对应的界面在早期版本
8
+ 就已下线,此后没有任何 `t()` 调用点,只是留在字典里被 `locale/*.json` 与打包产物带着走。
9
+ - **删除 2 个重复定义的界面标签**:`familyLegacyDiag`(旧版共载 / Legacy co-load)、`familyPeers`(同页实例 / Copies on page)。
10
+ 它们在 zh、en 两个字典里各被**重复定义两次**(后一份静默覆盖前一份),且都没有调用点。同一功能的
11
+ `familyLegacyToast` / `familySuppressed` 仍在使用,保留;**运行时状态字段** `familyPeers`(family 接管详情里的同页实例列表)
12
+ 是数据而非标签,同样保留。
13
+ - **字典规模**:`client.js` 每个语言从 272 键降到 266 键,zh / en 键集仍完全一致。
14
+ - **防回流**:`test/contract-test.mjs` 的「退役键必须在两个字典里都是 undefined」断言从 4 个键扩到 10 个。
15
+
16
+ ## 2.0.0
17
+
18
+ **主题:追问卡片标题栏的 `×` 从「隐藏整个浮层」改成「删除这条追问对话」——一次 Breaking 的语义重定义,顺带把「隐藏浮层」这套能力整体下线,并移除反馈表单里「用邮件客户端发送」这条出口。**
19
+
20
+ `×` 原来是全局开关:点一下整个浮层缩成一个带计数的圆点,再点圆点恢复。用户实际想要的是**删掉眼前这条追问**,
21
+ 而当时删除只能绕两条路走(卡片底部按钮 / 设置页历史管理器),且 `×` 的「隐藏」在多卡场景下会连着别的卡片一起收走。
22
+ 2.0.0 把 `×` 直接对齐到用户心智,并**不再保留**「隐藏整个浮层」这套能力(不是挪位置,是不留替代入口)。
23
+
24
+ ### Breaking
25
+
26
+ - **`×` 语义变更**:卡片标题栏最右侧的 `×` 由「隐藏整个浮层」改为「**删除这条追问对话**」,`title` / `aria-label`
27
+ 改用新键 `cardDelete`。删除会同时从活动卡片列表与持久历史里移除该卡片,**不可撤销**。
28
+ - **`floatMode` 移除 `off`**:枚举从 `['capsule', 'full', 'off']` 收窄为 `['capsule', 'full']`,设置页该下拉只剩两项。
29
+ 旧配置里存过 `'off'` 的**无需手工迁移**:宿主 `sanitizeLayer` 判定为非法值 → 忽略并在 `problems` 里提示,
30
+ 回退默认 `capsule`(不写迁移代码,也不静默改写用户配置)。
31
+ - **小圆点 launcher 删除**:浮层不再有「只剩一个计数圆点」的形态,相关渲染分支、样式与恢复入口一并删除。
32
+ 浮层只剩 `capsule`(一行高胶囊)与 `full`(完整卡片栈,铺满右栏 dock 时为 `dock`)。
33
+ - **删除 4 个 i18n 键**:`setFloatModeOff`(设置页「隐藏」项)、`floatHide`(`×` 的旧 title)、
34
+ `floatRestore`(从圆点恢复浮层的入口)、`floatCount`(圆点计数)。`floatStreaming` / `floatDone` 保留(胶囊仍在用)。
35
+
36
+ ### 新增:删除交互
37
+
38
+ - **空卡直接删**:该卡无任何已完成追问轮(`history.length === 0`)且主回答正文与思考过程都为空、且不在作答中
39
+ (非 `pending` / `streaming`)时,点 `×` **直接删除,不弹确认**。
40
+ - **其余情况弹插件内轻量确认层**(`DeleteConfirmDialog`,不使用 `window.confirm`):标题「删除这条追问对话?」,
41
+ 正文说明会同时从浮层与追问历史里移除、不可撤销;已有追问轮次时追加一行「含 {n} 轮追问」;
42
+ 两个按钮「取消」/「删除」(删除为危险色 `.dsa-btn-danger`)。
43
+ - **无障碍与误触防护**:`role="dialog"` / `aria-modal="true"` / `aria-label` 取标题;默认焦点固定在被标记 `autoFocus`
44
+ 的「取消」上(不落在危险按钮,防连按 Enter);`Esc` 取消(`stopPropagation`,不冒泡到卡片自身的 Esc 处理)、
45
+ 点遮罩取消(仅当 `event.target === event.currentTarget`);确认层打开时卡片的 `R` / `C` / `Esc` 快捷键全部失效。
46
+ - **流式中删除**:先 `abort()` 中止请求 + 通知宿主 `cancel`,丢弃该卡进行中的增量缓冲(`pendingDeltas` / `pendingReasonings`),再执行删除。
47
+ - **删除动作顺序固定**:按同一 `id` 从 `history` 与 `cards` 双移除 → 立即写盘持久化 → 关闭该卡已打开的侧栏 / host tab 表面
48
+ → 浮层 toast「已删除这条追问对话」(2600ms 自动消失)。
49
+ - **删除后重排**:剩余卡片按原顺序塌缩补位;删掉最后一张时 `total === 0` → 浮层整体不渲染(无空态)。
50
+ - **上下文隔离**:每张卡的追问上下文本就按卡隔离,删除不影响其它卡片;后续生成只读取剩余卡片自身历史。
51
+
52
+ ### 移除:反馈表单的「用邮件客户端发送」出口
53
+
54
+ - **UI**:反馈弹窗从三条出口减到两条(`发送` / `复制内容`),删掉 `data-feedback-mailto` 按钮与
55
+ `sendFeedbackByMailApp()`;没有发信服务时弹窗仍按两种就绪状态说明缺什么,并指向「复制内容」。
56
+ - **Host**:删掉 `POST /feedback-mailto` 路由、`buildMailtoUrl()` 构造器与 `API_METHODS` 里的条目。
57
+ 未命中的方法由既有兜底返回 `404 not-found`(`error.message` 带方法名),旧页面调用会明确失败而不是静默。
58
+ - **`/state`**:`capabilities.feedback.transport` 在未配置时由 `'mailto'` 改为 `'none'`;`reason` 只回 SMTP 的原因。
59
+ - **i18n**:`feedbackMailto` 删除,`feedbackMailtoOnly` 重命名为 `feedbackNoCredentials`(文案改指向「复制内容」),
60
+ `feedbackUnconfigured` 同步改写;zh/en 两侧键集仍然完全一致。
61
+ - **理由**:这条出口只是把同一封邮件交给系统邮件应用,与「复制内容」的实际效果重叠,却让 Host 多出一个
62
+ 任何人都能调的 URL 构造接口(还会把明文收件地址拼进 URL)。删掉它,反馈的发送面只剩 SMTP 一条。
63
+ - **测试**:`test/contract-test.mjs` 删除 `buildMailtoUrl` / 路由 200 / 按钮存在三类断言,改为断言
64
+ `transport === 'none'`、`/feedback-mailto` 两次 404(有/无收件人)、`data-feedback-mailto` 不存在、
65
+ 两个退役 i18n 键在 zh/en 都是 `undefined`(`node test/verify.mjs` 的 `t()` 键扫描同时覆盖新键名)。
66
+
67
+ ### 清理:1.9.1 遗留的 10 个死 i18n 键
68
+
69
+ - **删掉 10 个 `feedbackSmtp*` 标签键**:`feedbackSmtpShow` / `feedbackSmtpHide` / `feedbackSmtpEnvNote` /
70
+ `feedbackSmtpHost` / `feedbackSmtpSecure` / `feedbackSmtpPassSaved` / `feedbackSmtpPass` / `feedbackSmtpFrom` /
71
+ `feedbackSmtpHint` / `feedbackSmtpSaveHint`。它们只属于 1.9.1 删掉的设置页 SMTP 表单,全仓已无任何
72
+ `t('feedbackSmtp…')` 调用点,留着只是让翻译者以为这些输入框还在。
73
+ - **配置键不动**:`feedbackSmtpHost` / `feedbackSmtpPort` / `feedbackSmtpSecure` / `feedbackSmtpUser` /
74
+ `feedbackSmtpPass` / `feedbackSmtpFrom` 作为 `config.json` 字段与 `client.js` 的 `CLIENT_DEFAULTS` 继续存在,
75
+ 按环境变量或用户层配置生效;删的只是同名的**界面标签**。
76
+ - **zh/en 同步删**,键集仍完全一致;`test/contract-test.mjs` 的退役键断言扩到 12 个键(含上一节的两个),
77
+ 两个字典都必须取到 `undefined`。
78
+
79
+ ### 兼容性与边界
80
+
81
+ - **不涉及**:无服务端会话存储、无多端同步、无遥测埋点(这些维度 N/A);删除不进「回收站」,**不提供撤销**
82
+ (无 Undo、无快照、无 5 秒窗口)。
83
+ - **承载面差异**:`×` 只出现在**浮层 / 内嵌浮层**(含 dock 到右侧栏的浮层卡片)上。被宿主 tab 承载的同一张卡
84
+ (`better-sidebar` / `native-rightbar` 独立标签页)**不提供 `×`**,沿用它自己的标签关闭 + 底部「关闭」——
85
+ 「关闭」只把卡片摘离浮层、**历史仍保留**在设置页历史管理器里,与「删除」是两种真实需求。
86
+ - **版本号**:`1.9.1 → 2.0.0`,三处同步(`package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION`、`client.js` 的 `CLIENT_VERSION`)。
87
+ - **测试**:`test/float-placement.mjs` 增补 2.0.0 行为断言(无 `dsa-launcher` / `floatRestore`、`floatControl('capsule')` 存在、
88
+ `onDelete` 已接线、无 `mode === 'off'` 渲染分支、`floatMode` 默认 `capsule`);`test/contract-test.mjs` 增补
89
+ `sanitizeLayer` 丢弃 `'off'`、设置页 option 序列为 `['capsule','full']`、卡片头恰有一枚 `aria-label === cardDelete` 的按钮、
90
+ `deleteCard` 的四类行为、4 个已删键为 `undefined`。
91
+ - **文档**:README 的配置表 / dock 形态说明 / 自测第 8–9 条同步改写,反馈章节的「三条出口」表改为两条并记下移除理由;
92
+ `docs/PRD-followup-delete.md` 出 v3 修订(D0 定稿 + 隐藏能力下线 + 规格逐条对齐已交付代码,路由清单去掉 `feedback-mailto`)。
93
+
3
94
  ## 1.9.1
4
95
 
5
96
  **主题:反馈入口重做——从「设置页里一整块常驻表单」改成「顶部一行横幅 + 居中弹窗」。**
package/README.md CHANGED
@@ -22,7 +22,8 @@
22
22
 
23
23
  > **1.5.0 增强一览**(详见 CHANGELOG):内置浮层不再压住主对话——宽度改由纯函数 `floatPlacement`
24
24
  > 按「对话区右侧可用带宽」计算(旧实现是固定 400px,在用户截图上压入正文约 113px);浮层默认是**一行高的胶囊**,
25
- > 可随时「收起 / 隐藏」,运行时的形态切换不写回配置;新增 `floatMode`(默认 `capsule`)与
25
+ > 可随时「收起为胶囊」,运行时的形态切换不写回配置;卡片标题栏右侧的 `×` 自 2.0.0 起是**删除这条追问对话**
26
+ > (连同追问历史一起删),不再是隐藏浮层;新增 `floatMode`(默认 `capsule`)与
26
27
  > `floatMaxWidth`(默认 340)两项设置;卡片阴影减半。
27
28
 
28
29
  > **1.4.0 增强一览**(详见 CHANGELOG):卡片**思考过程**(reasoning)可折叠展示并随答案流式渲染;
@@ -163,9 +164,9 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
163
164
  | `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
164
165
  | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
165
166
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
166
- | `floatMode` | `capsule` \| `full` \| `off` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈 / 只留一个计数圆点。运行时可即时收起、隐藏,但不写回本项——刷新后仍回到这里设的默认 | 立即 |
167
+ | `floatMode` | `capsule` \| `full` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 `off` 的会在启动时校验失败、回退为 `capsule`,并在 `problems` 里给出提示(`index.js` 的 `sanitizeLayer`),无需手工迁移 | 立即 |
167
168
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
168
- | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、关闭浮层、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
169
+ | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、点卡片底部「关闭」、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
169
170
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
170
171
  | `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
171
172
  | `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
@@ -181,7 +182,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
181
182
  | `answerStyle` | `concise` \| `standard` \| `detailed` | `standard` | **回答风格档位**:只给最小完整答案 / 现状 / 必要时展开并给出依据。指令追加在人格之后,**不会**覆盖"用提问的语言作答"与"选中文本是数据"这两条 | 下一次提问 |
182
183
  | `customPersona` | 字符串,≤500 码点 | `''` | **自定义指令**:追加在风格档位之后,以 `Additional user instruction:` 标注随每次提问发送(例如"尽量给可验证来源") | 下一次提问 |
183
184
  | `followUpSuggestions` | 布尔 | `false` | **模型生成的建议追问**:让模型在答案末尾用 `<followups>问1 \| 问2</followups>` 给出 2–3 个可点的下一步问题(客户端剥离该块并渲染为芯片,**不额外发请求**) | 下一次提问 |
184
- | `feedbackAddress` | 字符串(邮箱或 `b64:` 编码)\| `''` | 维护者收件箱(`b64:` 混淆存储) | **反馈收件邮箱**:设置页「反馈」弹窗把问题与建议发到这里。默认值指向维护者自己的邮箱,以 `b64:<base64>` 形式打包——**这是混淆不是加密**,`decodeAddress()` 就在同包里,只能挡住正则抓取;环境变量 `DSH_SIDECARD_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_TO` **优先于**本项,置空则只留「用邮件客户端发送」回退路径 | 立即 |
185
+ | `feedbackAddress` | 字符串(邮箱或 `b64:` 编码)\| `''` | 维护者收件箱(`b64:` 混淆存储) | **反馈收件邮箱**:设置页「反馈」弹窗把问题与建议发到这里。默认值指向维护者自己的邮箱,以 `b64:<base64>` 形式打包——**这是混淆不是加密**,`decodeAddress()` 就在同包里,只能挡住正则抓取;环境变量 `DSH_SIDECARD_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_TO` **优先于**本项,置空则反馈弹窗只留「复制内容」 | 立即 |
185
186
  | `feedbackSmtpHost` | 字符串,≤255 | `''` | **SMTP 服务器**。1.9.1 起只从**环境变量或用户层配置**读。**环境变量优先于配置**(`DSH_SIDECARD_SMTP_URL` / `DSH_CRB_SMTP_URL` 等能凑出完整端点时,配置里的整段不生效);只有用户层配置会存它,**不进仓库** | 立即 |
186
187
  | `feedbackSmtpPort` | 数字,1–65535 | `465` | SMTP 端口。465 配隐式 TLS,587 配 STARTTLS(服务器没声明 STARTTLS 时不会硬升级) | 立即 |
187
188
  | `feedbackSmtpSecure` | 布尔 | `true` | 隐式 TLS(465)。关掉则按 STARTTLS 处理 | 立即 |
@@ -223,7 +224,7 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
223
224
  - 位置 = 右栏**标签条之下**的内容区矩形,宽度取右栏宽度,高度取到底部;右栏自己的标签条仍然露出、可点。
224
225
  - 卡片在这个形态下没有圆角/描边/阴影(`.dsa-card` 的装饰被 `[data-dsa-float-mode="dock"]` 覆盖),只有一条左边线与右栏内容区分界。
225
226
  - 标题栏(追问 + 徽标 + ⇥ / ×)与底部操作行固定上下两端,中间 `AnswerBody` 独立滚动——长答案不再把操作行推出视野。
226
- - 收起为胶囊(⇥)、关闭(×)、或把 `dockRail` 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。
227
+ - 收起为胶囊(⇥)、删除这条追问对话(×)、或把 `dockRail` 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。注意 × 是**删除**:卡片从浮层与追问历史里一并移除;只想摘离浮层、保留历史请用底部的「关闭」。
227
228
  - 折叠的右栏不会被误用:`railOnScreen()` 会拒绝停在帧外(`left >= frameWidth - 1`)或尺寸不足的面板,量不到就回退,绝不把卡片画到帧外。
228
229
  - 矩形从哪来:**优先量本插件自己渲染在右栏里的 `CardHost` 节点**(`[data-dsa-rail-host]`)——它所在的位置按定义就是「一页正文」,不依赖任何未被文档承诺的标记;找不到时再按 `data-sidebar-right-*`、dockkit 世代、`.rightbar`、`sidebar.right` 槽逐代回退。
229
230
  - 什么时候重算:窗口改宽、右栏开合(只盯属性的 `MutationObserver`)、右栏自身改宽(`ResizeObserver`),以及挂载后的四次延时重测(罩住右栏的滑入动画)。流式刷新不会触发几何重算。
@@ -242,7 +243,7 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
242
243
  | `no-rail` | 没找到已展开的右栏(右栏关着,或这个构建的标记与预期不同) |
243
244
  | `rail-too-small` | 找到了,但宽 < 280px / 高 < 240px,或它停在帧外(折叠态被拒绝) |
244
245
  | `off` | 设置里的 `dockRail` 被关掉了 |
245
- | `mode` | 当前不是完整卡片形态(胶囊 / 隐藏),本来就不该铺满 |
246
+ | `mode` | 当前不是完整卡片形态(胶囊 / 折叠态),本来就不该铺满 |
246
247
 
247
248
  ### 4.3 选区旁的动作条(外观)
248
249
 
@@ -269,14 +270,17 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
269
270
  它把真正的设置项全挤到屏幕外,被否掉后重做成现在这种「一条横幅 + 一个弹窗」。表单字段:类型(问题 / 建议 / 疑问 / 其他)、
270
271
  标题、详细描述、可选的**你的邮箱**(填了就写进邮件的 `Reply-To:`,维护者按「回复」能直接回给你;留空即匿名)。
271
272
  **设置页上没有任何 SMTP 输入框**:发信凭据属于部署方/维护者,走环境变量或用户层配置(见下),填报人不用管。
272
- 三条出口,从首选到兜底:
273
+ 两条出口,从首选到兜底:
273
274
 
274
275
  | 出口 | 何时可用 | 说明 |
275
276
  |---|---|---|
276
277
  | **发送**(推荐) | 收件邮箱已配置 **且** SMTP 凭证可用(环境变量或用户层配置) | 插件直接把邮件投给 SMTP 服务器;成功后清空草稿 |
277
- | **用邮件客户端发送** | 只要有收件邮箱 | 拼一个 `mailto:` 交给系统邮件应用,**不需要任何凭证** |
278
278
  | **复制内容** | 总是 | 把同样的纯文本(含版本与环境)放进剪贴板,用户自己粘到任意渠道 |
279
279
 
280
+ > 1.9.x 曾有的第三条出口「用邮件客户端发送」(`POST /feedback-mailto` 返回拼好的 `mailto:` URL)已在
281
+ > 2.0.0 整体移除:它让插件多出一条谁都可以调的出口,却只是把同一封邮件交给系统邮件应用,与「复制内容」的
282
+ > 实际效果重叠。现在没有发信服务时,弹窗直接说明缺什么,并指向「复制内容」。
283
+
280
284
  **收件邮箱默认可用,也可被环境变量接管。** 打包默认值是维护者邮箱(`b64:` 混淆存储,见上表);
281
285
  环境变量 `DSH_SIDECARD_FEEDBACK_TO` 或 `DSH_CRB_FEEDBACK_TO`(与 dsh-conversation-recycle-bin 共用)
282
286
  **优先**于它。用户层配置落在 `$DSH_HOME/sidecard-ask/config.json`
@@ -331,20 +335,19 @@ DSH_SIDECARD_SMTP_FROM="user@example.com" # 可选,默认等于 USER
331
335
  - `/state` 的 `config` 里也**删掉了** `feedbackAddress`,页面拿不到它;
332
336
  - 设置页不显示收件人的任何形式——没有掩码行、没有更换入口、没有地址输入框。收件人只由用户层配置
333
337
  `feedbackAddress` 或环境变量 `DSH_SIDECARD_FEEDBACK_TO` 提供,那是部署方/维护者的事,填写者不用管;
334
- - 「用邮件客户端发送」点击时向 Host 要一条**拼好的** `mailto:` URL(`POST /feedback-mailto`),
335
- 浏览器只负责跳转,不自己拼地址。
338
+ - 页面也**不会**拿到 `mailto:` URL:2.0.0 删掉了 `/feedback-mailto` 路由与其 URL 构造器,
339
+ 未命中的方法一律得到 `404 not-found`。
336
340
  - **`From:` 仍是维护者自己的 SMTP 账号,但填报人的邮箱会写进 `Reply-To:`**:QQ/163 会拒收不属于本账号的
337
341
  `From:`(553),所以发件身份不变;填报人在「你的邮箱」里填了地址,Host 就把它作为 `Reply-To:` 写进邮件头,
338
342
  维护者按「回复」直接回到填报人。该值在 Host 侧先过邮箱格式校验且不含 CR/LF,因此无法注入头字段;
339
- 留空则整行不写(匿名反馈)。`mailto:` 回退路径里没有 `Reply-To` 可用,同一个地址同时留在正文 `Contact:` 行。
343
+ 留空则整行不写(匿名反馈)。
340
344
 
341
- - 三种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 提示配置;有收件人但没有 SMTP →
342
- 提示「缺的只是发信凭据,现在可用『用邮件客户端发送』」;齐全 → 什么都不提示。
345
+ - 两种就绪状态在界面上分开说,不混成一个「未配置」:没有收件人 → 说没有可用发信服务;有收件人但没有 SMTP
346
+ 凭证 → 说「只缺发信凭据」;齐全 → 什么都不提示。两种情况都指向「复制内容」,不再留一条邮件应用捷径。
343
347
  - **关于「加密」的实话**:这里的邮箱只是一次 SMTP 的收件地址,插件必须能解出明文才能发信,任何随插件
344
348
  分发的「加密」都等于把钥匙一起发出去,只能算混淆——打包默认值用的 `b64:` 就是这种混淆,只挡正则抓取。
345
349
  真正有效的保护是**只存本机配置 + 明文不进浏览器 + 日志打码 + 可被环境变量整体接管**——这四条都已实现。
346
- 仍然避免不了的两处:`mailto:` URL 本身必然含明文(由 Host 组好直接交给系统邮件应用,不经页面渲染),
347
- 以及**已经提交进 git 历史的作者邮箱**(见下文)。
350
+ 仍然避免不了的一处是**已经提交进 git 历史的作者邮箱**(见下文)。
348
351
 
349
352
  失败排查顺序:`/state` 的 `capabilities.feedback.reason`(它会直接说「账号或密码还没填全」这类话)→
350
353
  用户层配置是否整行都在(服务器/端口/账号/密码;**端口必须是数字**,字符串会被判非法)→ 环境变量是否
@@ -369,7 +372,7 @@ DSH_SIDECARD_SMTP_FROM="user@example.com" # 可选,默认等于 USER
369
372
  这是 `auto` 的**第一选择**。
370
373
  - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是第二选择,也是保底路径**。
371
374
  浮层默认是**贴帧右边缘的胶囊**(一行高,只有状态与问题首句),点击才展开成完整卡片,
372
- 标题栏右侧的「收起 / 隐藏」可随时收回;它的宽度由 `floatPlacement` 计算,
375
+ 标题栏右侧的 ⇥ 可随时收起为胶囊、× 则删除这条追问对话(连同追问历史,不可撤销);它的宽度由 `floatPlacement` 计算,
373
376
  只会占用**对话区右侧那条预留带**,不会压住正文(细节见 §4.1)。
374
377
  - **原生右侧栏为什么退出 `auto`**:卡片被塞进右栏只是一个 tab,而这个 tab 的生命周期不归插件管——
375
378
  卡片关闭后右栏仍留着一条读作「该卡片已关闭」的空标签,和你自己的右栏标签抢位置,
@@ -712,16 +715,19 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
712
715
  「第 1 版」;点它 → 显示**旧问题 + 旧答案**并标注"正在查看历史版本(只读)",点「返回最新」回到新答案。
713
716
  刷新页面后历史卡片仍能切版本(v3 持久化)。连续改问 6 次后只保留最近 5 版。
714
717
  8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
715
- 展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
718
+ 展开后标题栏右侧有**收起为胶囊(⇥)**与**删除这条追问对话(×)**——× 会把该卡片从浮层与追问历史里
719
+ 一并移除,**无法撤销**(有内容时会先弹插件内确认层;点浮层底部的「关闭」则只把它摘离浮层、历史仍保留在
720
+ 设置页的历史管理器里)。2.0.0 起不再有「隐藏整个浮层 / 只留一个计数圆点」这套形态。
716
721
  用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
717
- - `data-dsa-float-mode` 应为 `capsule` / `full` / `off`;
722
+ - `data-dsa-float-mode` 应为 `capsule` / `full`(铺满右栏时为 `dock`);
718
723
  - `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`。
719
724
  **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
720
725
  把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
721
726
  只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
722
727
  9. **占满右侧栏(1.8.3,`dockRail`)**:先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 →
723
728
  答案卡应铺满**标签条下方**的整块内容区:右边缘贴帧边、下边缘贴窗口底,右栏的标签条仍然露出可点。
724
- 点卡片标题栏的 ⇥(收起为胶囊)或 × → 卡片立即退出占满、回到常规浮层,右栏内容区恢复。
729
+ 点卡片标题栏的 ⇥(收起为胶囊)→ 卡片立即退出占满、回到常规浮层,右栏内容区恢复;点 × 则是删除这条
730
+ 追问对话(同样退出占满,但卡片与历史都不再存在)。
725
731
  自检:F12 读浮层根节点,`data-dsa-float-mode` 应为 `dock`,`data-dsa-dock` 也应为 `dock`;
726
732
  `.dsa-stack` 上的 `data-dsa-dock-body` 应等于卡片宽度(px)。**没铺满就看 `data-dsa-dock`**:
727
733
  `no-rail` = 没找到右栏,`rail-too-small` = 找到了但太窄或在帧外,`off` = 开关关了,`mode` = 不是完整形态。
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.9.1'
50
+ const CLIENT_VERSION = '2.0.1'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -466,8 +466,6 @@ window.__ModuleLoader__.load({
466
466
  zoneCross: '跨区选择',
467
467
  chars: '{n} 字',
468
468
  truncatedBadge: '已截断 {n} 字',
469
- selectionEmpty: '没有选中文本',
470
- selectionTooShort: '选中文本太短,未触发追问',
471
469
  answerTitle: '追问',
472
470
  answerStreaming: '作答中…',
473
471
  answerDone: '已完成',
@@ -476,6 +474,12 @@ window.__ModuleLoader__.load({
476
474
  answerEmpty: '(没有内容)',
477
475
  cardClosed: '该卡片已关闭',
478
476
  cardMovedToFlow: '该卡片已移至浮层显示',
477
+ cardDelete: '删除这条追问对话',
478
+ cardDeleteTitle: '删除这条追问对话?',
479
+ cardDeleteBody: '会同时从浮层和追问历史里移除,且不可撤销。',
480
+ cardDeleteRounds: '含 {n} 轮追问',
481
+ cardDeleteConfirm: '删除',
482
+ cardDeleted: '已删除这条追问对话',
479
483
  reasoningTitle: '思考过程',
480
484
  copy: '复制',
481
485
  copied: '已复制',
@@ -528,18 +532,13 @@ window.__ModuleLoader__.load({
528
532
  setFloatMode: '浮层默认形态',
529
533
  setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
530
534
  setFloatModeFull: '直接展开成卡片',
531
- setFloatModeOff: '关闭浮层(只留小圆点)',
532
535
  setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
533
536
  setFloatMaxWidth: '浮层宽度上限(px)',
534
537
  setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
535
538
  setDockRail: '右侧栏打开时占满它',
536
539
  setDockRailHint: '答案直接铺满右侧栏(读得像栏内的一页),收起为胶囊或关闭即恢复原状;右侧栏不在时不生效',
537
- floatDocked: '已占满右侧栏',
538
540
  floatExpand: '展开侧边卡片',
539
541
  floatCollapse: '收起为胶囊(不遮挡对话)',
540
- floatHide: '隐藏浮层(只留小圆点)',
541
- floatRestore: '恢复浮层',
542
- floatCount: '{n} 条追问回答',
543
542
  floatStreaming: '正在作答…',
544
543
  floatDone: '已完成 {n} 条',
545
544
  setShortcut: '快捷键',
@@ -563,7 +562,6 @@ window.__ModuleLoader__.load({
563
562
  saved: '已保存',
564
563
  saveFailed: '保存失败:{msg}',
565
564
  reset: '重置为 patch 配置',
566
- resetDone: '已重置',
567
565
  diagnostics: '运行自检',
568
566
  diagTitle: '能力自检',
569
567
  diagHost: '宿主接口',
@@ -589,8 +587,6 @@ window.__ModuleLoader__.load({
589
587
  surfaceUnavailable: '所选侧边承载面不可用',
590
588
  familySuppressed: '检测到同系列更新版本({version})已在本页运行,本实例已自动停用划词触发与快捷键,界面由更新版本接管。建议在插件页卸载本旧版本。',
591
589
  familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
592
- familyLegacyDiag: '旧版共载',
593
- familyPeers: '同页实例',
594
590
  retryEdit: '改问',
595
591
  editQuestionPlaceholder: '修改问题后重新提问…',
596
592
  retryAskSubmit: '重新提问',
@@ -686,8 +682,6 @@ window.__ModuleLoader__.load({
686
682
  versionLatest: '返回最新',
687
683
  versionViewing: '正在查看历史版本(只读)',
688
684
  suggestionsLabel: '建议追问',
689
- familyLegacyDiag: '旧版共载',
690
- familyPeers: '同页实例',
691
685
  setFeedback: '反馈',
692
686
  setFeedbackHint: '遇到问题或有建议,在这里直接发邮件给我;邮件会自动附带插件版本与运行环境(不含聊天内容)',
693
687
  feedbackOpen: '反馈问题',
@@ -709,26 +703,15 @@ window.__ModuleLoader__.load({
709
703
  feedbackSend: '发送邮件',
710
704
  feedbackSending: '发送中…',
711
705
  feedbackSent: '已发送,谢谢反馈',
712
- feedbackMailto: '用邮件客户端发送',
713
706
  feedbackCopy: '复制内容',
714
707
  feedbackCopied: '已复制到剪贴板',
715
- feedbackUnconfigured: '没有可用的发信服务,可用“用邮件客户端发送”自行寄出',
716
- feedbackMailtoOnly: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);现在也可以“用邮件客户端发送”',
708
+ feedbackUnconfigured: '没有可用的发信服务,暂时不能直接寄出;可用“复制内容”自己发给我',
709
+ feedbackNoCredentials: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);可用“复制内容”自己发给我',
717
710
  feedbackNeedsTitle: '标题至少 4 个字',
718
711
  feedbackNeedsBody: '详细描述至少 10 个字',
719
712
  feedbackContactTooLong: '联系方式太长(≤{n} 字)',
720
713
  feedbackFailed: '发送失败:{message}',
721
714
  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
715
  feedbackTooFast: '刚发过一封,请稍等半分钟',
733
716
  feedbackTooMany: '发送过于频繁,请稍后再试',
734
717
  },
@@ -751,8 +734,6 @@ window.__ModuleLoader__.load({
751
734
  zoneCross: 'Cross-region',
752
735
  chars: '{n} chars',
753
736
  truncatedBadge: 'truncated {n} chars',
754
- selectionEmpty: 'Nothing selected',
755
- selectionTooShort: 'Selection too short to trigger',
756
737
  answerTitle: 'Follow-up',
757
738
  answerStreaming: 'Answering…',
758
739
  answerDone: 'Done',
@@ -761,6 +742,12 @@ window.__ModuleLoader__.load({
761
742
  answerEmpty: '(empty)',
762
743
  cardClosed: 'This card was closed',
763
744
  cardMovedToFlow: 'This card moved to the floating stack',
745
+ cardDelete: 'Delete this follow-up conversation',
746
+ cardDeleteTitle: 'Delete this follow-up conversation?',
747
+ cardDeleteBody: 'It will be removed from both the floating layer and the follow-up history. This cannot be undone.',
748
+ cardDeleteRounds: 'Includes {n} follow-up turn(s)',
749
+ cardDeleteConfirm: 'Delete',
750
+ cardDeleted: 'Deleted this follow-up conversation',
764
751
  reasoningTitle: 'Reasoning',
765
752
  copy: 'Copy',
766
753
  copied: 'Copied',
@@ -813,18 +800,13 @@ window.__ModuleLoader__.load({
813
800
  setFloatMode: 'Floating layer default',
814
801
  setFloatModeCapsule: 'Capsule (never covers the conversation)',
815
802
  setFloatModeFull: 'Expanded card stack',
816
- setFloatModeOff: 'Hidden (small launcher only)',
817
803
  setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
818
804
  setFloatMaxWidth: 'Floating layer max width (px)',
819
805
  setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
820
806
  setDockRail: 'Fill the right rail while it is open',
821
807
  setDockRailHint: 'The answer fills the right rail (it reads as one of its pages) and comes back to normal when collapsed to the capsule or closed; ignored while no rail is on screen',
822
- floatDocked: 'Filled the right rail',
823
808
  floatExpand: 'Expand the side cards',
824
809
  floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
825
- floatHide: 'Hide the layer (leaves a small launcher)',
826
- floatRestore: 'Restore the floating layer',
827
- floatCount: '{n} follow-up answers',
828
810
  floatStreaming: 'Answering…',
829
811
  floatDone: '{n} done',
830
812
  setShortcut: 'Shortcut',
@@ -848,7 +830,6 @@ window.__ModuleLoader__.load({
848
830
  saved: 'Saved',
849
831
  saveFailed: 'Save failed: {msg}',
850
832
  reset: 'Reset to patch config',
851
- resetDone: 'Reset',
852
833
  diagnostics: 'Run diagnostics',
853
834
  diagTitle: 'Capability check',
854
835
  diagHost: 'Host API',
@@ -874,8 +855,6 @@ window.__ModuleLoader__.load({
874
855
  surfaceUnavailable: 'The chosen side surface is unavailable',
875
856
  familySuppressed: 'A newer family build ({version}) is already running on this page; this copy has disabled its trigger and shortcut so only one UI answers. Uninstall this older package.',
876
857
  familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
877
- familyLegacyDiag: 'Legacy co-load',
878
- familyPeers: 'Copies on page',
879
858
  retryEdit: 'Edit & retry',
880
859
  editQuestionPlaceholder: 'Edit the question, then ask again…',
881
860
  retryAskSubmit: 'Ask again',
@@ -971,8 +950,6 @@ window.__ModuleLoader__.load({
971
950
  versionLatest: 'Back to latest',
972
951
  versionViewing: 'Viewing an older version (read-only)',
973
952
  suggestionsLabel: 'Suggested follow-ups',
974
- familyLegacyDiag: 'Legacy co-load',
975
- familyPeers: 'Copies on page',
976
953
  setFeedback: 'Feedback',
977
954
  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
955
  feedbackOpen: 'Send feedback',
@@ -994,26 +971,15 @@ window.__ModuleLoader__.load({
994
971
  feedbackSend: 'Send mail',
995
972
  feedbackSending: 'Sending…',
996
973
  feedbackSent: 'Sent — thank you',
997
- feedbackMailto: 'Send with my mail app',
998
974
  feedbackCopy: 'Copy text',
999
975
  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”',
976
+ feedbackUnconfigured: 'No mail service is configured, so the report cannot be sent from here; use “Copy text” and send it yourself',
977
+ 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',
1002
978
  feedbackNeedsTitle: 'The title needs at least 4 characters',
1003
979
  feedbackNeedsBody: 'The details need at least 10 characters',
1004
980
  feedbackContactTooLong: 'The contact line is too long (max {n} characters)',
1005
981
  feedbackFailed: 'Send failed: {message}',
1006
982
  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
983
  feedbackTooFast: 'One mail just went out — wait half a minute',
1018
984
  feedbackTooMany: 'Too many reports for now — try again later',
1019
985
  },
@@ -1238,8 +1204,8 @@ window.__ModuleLoader__.load({
1238
1204
  surface: 'flow',
1239
1205
  surfaceNote: null,
1240
1206
  /**
1241
- * In-session override of `config.floatMode`, set by the layer's own
1242
- * collapse/hide buttons. `null` = follow the configured default.
1207
+ * In-session override of `config.floatMode`, set by the card header's
1208
+ * collapse button. `null` = follow the configured default.
1243
1209
  */
1244
1210
  floatMode: null,
1245
1211
  sessionId: null,
@@ -1384,10 +1350,10 @@ window.__ModuleLoader__.load({
1384
1350
  * NOT persisted: the settings page owns the durable default, and a reload
1385
1351
  * should return to it (a pill hidden by accident must not stay hidden
1386
1352
  * across sessions).
1387
- * @param {string} mode - `capsule` | `full` | `off`.
1353
+ * @param {string} mode - `capsule` | `full`.
1388
1354
  */
1389
1355
  function setFloatMode(mode) {
1390
- if (mode !== 'capsule' && mode !== 'full' && mode !== 'off') return
1356
+ if (mode !== 'capsule' && mode !== 'full') return
1391
1357
  store.set({ floatMode: mode })
1392
1358
  }
1393
1359
 
@@ -1789,7 +1755,7 @@ window.__ModuleLoader__.load({
1789
1755
  return { ok: true }
1790
1756
  }
1791
1757
 
1792
- /** One-line subject for the report (also used by the `mailto:` fallback). */
1758
+ /** One-line subject for the report. */
1793
1759
  function feedbackSubjectFor({ version, draft }) {
1794
1760
  const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
1795
1761
  const kind = typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'
@@ -2216,7 +2182,7 @@ window.__ModuleLoader__.load({
2216
2182
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
2217
2183
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
2218
2184
  Every surface we draw declares it. The property is inert in a browser. */
2219
- .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}
2185
+ .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}
2220
2186
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
2221
2187
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
2222
2188
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -2334,9 +2300,16 @@ body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
2334
2300
  html[data-ds-dark-theme] .dsa-btn-primary,
2335
2301
  body[data-ds-dark-theme] .dsa-btn-primary{color:#0a1428}
2336
2302
  .dsa-btn:disabled{opacity:.55;cursor:default}
2337
- /* Icon-only variant (the floating layer's collapse/hide controls): square and
2338
- quiet so the card title bar stays one line and the title keeps the weight. */
2303
+ /* Icon-only variant (the floating layer's collapse / delete controls): square
2304
+ and quiet so the card title bar stays one line and the title keeps the weight. */
2339
2305
  .dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
2306
+ /* Destructive confirmation only: the irreversible button carries the error token
2307
+ so it can never be mistaken for the safe one rendered next to it. Placed after
2308
+ .dsa-btn-icon on purpose — same specificity, so source order decides. */
2309
+ .dsa-btn-danger{border-color:var(--dsw-alias-state-error-primary, #c0392b);
2310
+ background:var(--dsw-alias-state-error-primary, #c0392b);color:#fff;font-weight:500}
2311
+ .dsa-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary, #c0392b);
2312
+ background:var(--dsw-alias-state-error-primary, #c0392b)}
2340
2313
  /* Segmented control (main chat / side card). These are the two destinations an
2341
2314
  answer can land in, so the pair has to read as a radio choice, not as two
2342
2315
  quiet labels: the group gets a recessed track with an explicit mid-grey edge
@@ -2422,11 +2395,18 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2422
2395
  .dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
2423
2396
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2424
2397
  .dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
2425
- .dsa-launcher{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;padding:6px 11px;
2426
- border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
2427
- border:1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.45));
2428
- background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-secondary, #666)}
2429
- .dsa-launcher:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
2398
+ /* Delete confirmation (2.0.0). Fixed, not inline: a card docked into the right
2399
+ rail lives inside an overflow:hidden page, which would clip a sheet anchored
2400
+ to the card. Same backdrop recipe as the feedback dialog. */
2401
+ .dsa-confirm-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;
2402
+ justify-content:center;padding:24px;background:rgba(0,0,0,.42)}
2403
+ .dsa-confirm{display:flex;flex-direction:column;gap:8px;width:min(360px, 100%);padding:14px 16px;
2404
+ border-radius:12px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
2405
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
2406
+ box-shadow:0 18px 48px rgba(0,0,0,.28)}
2407
+ .dsa-confirm-title{font-size:13px;font-weight:600}
2408
+ .dsa-confirm-body{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary, #666)}
2409
+ .dsa-confirm > .dsa-row{justify-content:flex-end}
2430
2410
  .dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
2431
2411
  .dsa-answer .dsa-p{margin:0 0 6px}
2432
2412
  .dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
@@ -4007,15 +3987,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4007
3987
  }, t('close')))
4008
3988
  }
4009
3989
 
3990
+ /**
3991
+ * The confirmation gate for an irreversible action.
3992
+ *
3993
+ * A component rather than an inline block for two reasons: every
3994
+ * destructive control can reuse it, and the test harness cannot drive a
3995
+ * click-driven state change, so rendering it directly is the only way this
3996
+ * markup gets asserted (same seam as {@link FeedbackDialog}).
3997
+ *
3998
+ * The safe choice is the default: Cancel takes focus, Escape aborts, and
3999
+ * only a click on the backdrop itself dismisses — a click that lands on the
4000
+ * dialog must not throw away the decision the user is making.
4001
+ */
4002
+ function DeleteConfirmDialog({ title, body, detail, cancelLabel, confirmLabel, onCancel, onConfirm }) {
4003
+ return h('div', {
4004
+ className: 'dsa-confirm-backdrop',
4005
+ onClick: event => {
4006
+ if (event.target === event.currentTarget) onCancel()
4007
+ },
4008
+ onKeyDown: event => {
4009
+ if (event.key !== 'Escape') return
4010
+ // Escape is the safe exit, so it stops here: the card's own Esc
4011
+ // handler and the global shortcut layer must not also react.
4012
+ event.stopPropagation()
4013
+ onCancel()
4014
+ },
4015
+ },
4016
+ h('div', {
4017
+ className: 'dsa-confirm',
4018
+ role: 'dialog',
4019
+ 'aria-modal': 'true',
4020
+ 'aria-label': title,
4021
+ },
4022
+ h('strong', { className: 'dsa-confirm-title' }, title),
4023
+ h('div', { className: 'dsa-confirm-body' }, body),
4024
+ detail !== null && detail !== undefined && detail !== '' ? h('div', { className: 'dsa-muted' }, detail) : null,
4025
+ h('div', { className: 'dsa-row' },
4026
+ h('button', {
4027
+ type: 'button',
4028
+ className: 'dsa-btn',
4029
+ // Safe default: a stray Enter on an unreviewed dialog must not
4030
+ // destroy anything.
4031
+ autoFocus: true,
4032
+ onClick: onCancel,
4033
+ }, cancelLabel),
4034
+ h('button', {
4035
+ type: 'button',
4036
+ className: 'dsa-btn dsa-btn-danger',
4037
+ onClick: onConfirm,
4038
+ }, confirmLabel))))
4039
+ }
4040
+
4010
4041
  /**
4011
4042
  * One answer card; `embedded` is true inside a host's tab.
4012
4043
  *
4013
- * `onFloatMode` is passed ONLY by the floating layer: it renders the
4014
- * collapse/hide controls that let the user get the layer out of the way
4015
- * (the layer is the only surface that can cover the conversation). A host
4016
- * tab that embeds the same card passes no callback and gets no buttons.
4044
+ * `onFloatMode` / `onDelete` are passed ONLY by the floating layer. The
4045
+ * first renders the "collapse to a capsule" control that gets the layer out
4046
+ * of the way (the layer is the only surface that can cover the
4047
+ * conversation); the second renders the × that DELETES this follow-up
4048
+ * conversation — its live card *and* its durable history entry — after a
4049
+ * confirmation. A host tab that embeds the same card passes neither and
4050
+ * keeps its own tab close plus the footer Close button, which by design
4051
+ * detaches the card without touching the history.
4017
4052
  */
4018
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
4053
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode, onDelete, onLocate, onExport, onInsert, onReplace, onViewVersion }) {
4019
4054
  const [followOpen, setFollowOpen] = React.useState(false)
4020
4055
  const [draft, setDraft] = React.useState('')
4021
4056
  // Edit-&-retry editor: prefilled with the card's CURRENT question and
@@ -4023,6 +4058,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4023
4058
  // edit-retry regenerates THIS turn, keeping the card's earlier turns).
4024
4059
  const [editOpen, setEditOpen] = React.useState(false)
4025
4060
  const [editDraft, setEditDraft] = React.useState('')
4061
+ // Deleting a follow-up conversation is irreversible, so it goes through an
4062
+ // in-plugin confirmation — EXCEPT for a card that holds nothing: no
4063
+ // follow-up turn, no answer text, no reasoning, no run in flight. There is
4064
+ // nothing to lose there, and prompting on an empty card only trains the
4065
+ // user to dismiss the prompt that matters.
4066
+ const [deleteOpen, setDeleteOpen] = React.useState(false)
4026
4067
  const answerRef = React.useRef(null)
4027
4068
  const config = store.state.config
4028
4069
  // P2-5b: an older version is READ-ONLY content swapped into the same
@@ -4057,6 +4098,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4057
4098
  // without modifiers and never while composing or inside an editor.
4058
4099
  const onCardKeyDown = (event) => {
4059
4100
  if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return
4101
+ // The confirmation is modal: R/C must not act on a card the user is
4102
+ // currently deciding to delete.
4103
+ if (deleteOpen) return
4060
4104
  const tag = String(event.target?.tagName ?? '')
4061
4105
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON'
4062
4106
  || event.target?.closest?.('[contenteditable=""],[contenteditable="true"]') !== null) {
@@ -4085,6 +4129,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4085
4129
  if (typeof card.elapsedMs === 'number' && Number.isFinite(card.elapsedMs) && formatElapsed(card.elapsedMs) !== '') {
4086
4130
  metaBits.push(`${t('metaElapsed')} ${formatElapsed(card.elapsedMs)}`)
4087
4131
  }
4132
+ // "Empty" is judged on the card's own content, never on how it got here.
4133
+ const turns = Array.isArray(card.history) ? card.history.length : 0
4134
+ const busy = card.status === 'pending' || card.status === 'streaming'
4135
+ const nothingToLose = turns === 0
4136
+ && String(card.text ?? '').trim() === ''
4137
+ && String(card.reasoning ?? '').trim() === ''
4138
+ const requestDelete = () => {
4139
+ if (!busy && nothingToLose) {
4140
+ onDelete?.()
4141
+ return
4142
+ }
4143
+ setDeleteOpen(true)
4144
+ }
4088
4145
  return h('section', {
4089
4146
  className: 'dsa-card',
4090
4147
  'aria-label': t('answerTitle'),
@@ -4097,10 +4154,20 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4097
4154
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
4098
4155
  surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
4099
4156
  card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
4100
- typeof onFloatMode === 'function'
4157
+ typeof onFloatMode === 'function' || typeof onDelete === 'function'
4101
4158
  ? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
4102
- floatControl('capsule', 'floatCollapse', '⇥'),
4103
- floatControl('off', 'floatHide', '×'))
4159
+ typeof onFloatMode === 'function'
4160
+ ? floatControl('capsule', 'floatCollapse', '⇥')
4161
+ : null,
4162
+ typeof onDelete === 'function'
4163
+ ? h('button', {
4164
+ type: 'button',
4165
+ className: 'dsa-btn dsa-btn-icon',
4166
+ title: t('cardDelete'),
4167
+ 'aria-label': t('cardDelete'),
4168
+ onClick: () => requestDelete(),
4169
+ }, '×')
4170
+ : null)
4104
4171
  : null),
4105
4172
  h('pre', { className: 'dsa-quote' }, shown.question),
4106
4173
  versions.length > 0
@@ -4251,17 +4318,34 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4251
4318
  onFollowUp: () => setFollowOpen(open => !open),
4252
4319
  onClose,
4253
4320
  }),
4254
- surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
4321
+ surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null,
4322
+ deleteOpen
4323
+ ? h(DeleteConfirmDialog, {
4324
+ title: t('cardDeleteTitle'),
4325
+ body: t('cardDeleteBody'),
4326
+ detail: turns > 0 ? t('cardDeleteRounds', { n: turns }) : null,
4327
+ cancelLabel: t('askCancel'),
4328
+ confirmLabel: t('cardDeleteConfirm'),
4329
+ onCancel: () => setDeleteOpen(false),
4330
+ onConfirm: () => {
4331
+ setDeleteOpen(false)
4332
+ onDelete?.()
4333
+ },
4334
+ })
4335
+ : null)
4255
4336
  }
4256
4337
 
4257
4338
  /**
4258
4339
  * The flow surface, i.e. the floating layer (A).
4259
4340
  *
4260
- * Three shapes, all sharing one right-anchored container:
4341
+ * Two shapes, both sharing one right-anchored container:
4261
4342
  * - `capsule`: a single ~34px pill (status + truncated question). The
4262
4343
  * default, because a one-line pill cannot bury the conversation.
4263
4344
  * - `full`: the classic card stack, collapsed on demand.
4264
- * - `off`: only a small launcher, so nothing covers the page until asked.
4345
+ *
4346
+ * The former `off` shape (hide the layer behind a small launcher) is gone
4347
+ * as of 2.0.0: the card header's × deletes that follow-up conversation
4348
+ * instead, and "hide everything" was never a state this layer needed.
4265
4349
  *
4266
4350
  * The width comes from {@link floatPlacement}, so the layer stops growing
4267
4351
  * left as soon as it would cross the conversation column's right boundary.
@@ -4462,17 +4546,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4462
4546
  onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
4463
4547
  onViewVersion: index => actions.viewVersion(card, index),
4464
4548
  onFloatMode: setFloatMode,
4549
+ onDelete: () => actions.deleteCard(card),
4465
4550
  }))
4466
4551
 
4467
- if (mode === 'off') {
4468
- return container(h('button', {
4469
- type: 'button',
4470
- className: 'dsa-launcher',
4471
- title: t('floatRestore'),
4472
- onClick: () => setFloatMode('full'),
4473
- }, dot, t('floatCount', { n: total })))
4474
- }
4475
-
4476
4552
  if (mode === 'capsule') {
4477
4553
  return container(h('button', {
4478
4554
  type: 'button',
@@ -4486,8 +4562,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4486
4562
 
4487
4563
  // Docked: the card takes the rail's whole rectangle, so it reads as one of
4488
4564
  // the rail's pages rather than a 340px card hovering over it. The card
4489
- // keeps its own header (title + badges + collapse/hide controls), which is
4490
- // what makes the page dismissible without leaving the rail.
4565
+ // keeps its own header (title + badges + collapse / delete controls),
4566
+ // which is what makes the page dismissible without leaving the rail.
4491
4567
  return container(h('div', {
4492
4568
  className: 'dsa-stack',
4493
4569
  'data-dsa-dock-body': docked ? String(dock.width) : undefined,
@@ -4554,7 +4630,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4554
4630
  function FeedbackDialog(props) {
4555
4631
  const {
4556
4632
  t, limits, draft, onChange, status, error, ready, reason, hasRecipient,
4557
- onClose, onSubmit, onMailto, onCopy,
4633
+ onClose, onSubmit, onCopy,
4558
4634
  } = props
4559
4635
  const sending = status === 'sending'
4560
4636
  // Escape closes it: a modal without a keyboard exit is a trap.
@@ -4641,15 +4717,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4641
4717
  })),
4642
4718
  h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackContactHint')),
4643
4719
  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.
4720
+ // Readiness: two states, not one. No recipient at all, or a recipient
4721
+ // but no usable transport. When the Host explains why, print its
4722
+ // sentence verbatim.
4647
4723
  ready === true
4648
4724
  ? null
4649
4725
  : h('span', { className: 'dsa-feedback-dialog-hint', 'data-feedback-readiness': 'unready' },
4650
4726
  String(reason ?? '') !== ''
4651
4727
  ? String(reason)
4652
- : (hasRecipient === true ? t('feedbackMailtoOnly') : t('feedbackUnconfigured'))),
4728
+ : (hasRecipient === true ? t('feedbackNoCredentials') : t('feedbackUnconfigured'))),
4653
4729
  h('div', { className: 'dsa-feedback-dialog-actions' },
4654
4730
  h('button', {
4655
4731
  type: 'button',
@@ -4659,12 +4735,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4659
4735
  title: ready === true ? undefined : t('feedbackUnconfigured'),
4660
4736
  onClick: onSubmit,
4661
4737
  }, 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
4738
  h('button', {
4669
4739
  type: 'button',
4670
4740
  className: 'dsa-btn',
@@ -4758,32 +4828,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4758
4828
  }
4759
4829
  }
4760
4830
 
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
4831
  const copyFeedbackDraft = async () => {
4788
4832
  const text = feedbackPlainTextFor({
4789
4833
  version: state.version ?? CLIENT_VERSION,
@@ -5025,8 +5069,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5025
5069
  // form. Nothing else about feedback lives on this page: the transport is
5026
5070
  // the Host's business (environment / user config), so an end user has
5027
5071
  // 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.
5072
+ // no SMTP endpoint is configured — the dialog says so in one line and
5073
+ // points at the copy path instead of looking broken.
5030
5074
  const feedbackConfigured = feedbackInfo !== null && feedbackInfo.configured === true
5031
5075
  const feedbackBanner = h('div', { className: 'dsa-feedback-bar', 'data-dsa-feedback-section': '' },
5032
5076
  h('div', { className: 'dsa-feedback-bar-text' },
@@ -5055,7 +5099,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5055
5099
  hasRecipient: feedbackInfo?.hasRecipient === true,
5056
5100
  onClose: () => setFeedbackOpen(false),
5057
5101
  onSubmit: () => { void submitFeedback() },
5058
- onMailto: sendFeedbackByMailApp,
5059
5102
  onCopy: () => { void copyFeedbackDraft() },
5060
5103
  })
5061
5104
 
@@ -5094,7 +5137,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5094
5137
  field(t('setFloatMode'), select('floatMode', [
5095
5138
  ['capsule', t('setFloatModeCapsule')],
5096
5139
  ['full', t('setFloatModeFull')],
5097
- ['off', t('setFloatModeOff')],
5098
5140
  ]), t('setFloatModeHint')),
5099
5141
  field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
5100
5142
  field(t('setMinChars'), number('minChars', 0, 200)),
@@ -5565,7 +5607,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5565
5607
  } else {
5566
5608
  store.set({ cards: [historyEntryToCard(entry), ...store.state.cards] })
5567
5609
  }
5568
- // A hidden (off/capsule) layer would swallow the restored card;
5610
+ // A collapsed (capsule) layer would swallow the restored card;
5569
5611
  // this is a session-only override, exactly like clicking expand.
5570
5612
  setFloatMode('full')
5571
5613
  toast(t('historyRestored'))
@@ -5583,6 +5625,57 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5583
5625
  })
5584
5626
  toast(t('historyDeleted'))
5585
5627
  },
5628
+ /**
5629
+ * Delete ONE follow-up conversation: the live card AND its durable
5630
+ * history entry. This is the card header's ×.
5631
+ *
5632
+ * Deliberately stronger than {@link actions.close}: closing only
5633
+ * detaches the card from the live surface and keeps the history entry
5634
+ * on purpose (#6), while this removes the same id from both stores and
5635
+ * persists immediately, so a reload cannot resurrect it.
5636
+ *
5637
+ * An in-flight run is released first: the browser stream is aborted,
5638
+ * the Host child is told to cancel, and the card's buffered deltas are
5639
+ * dropped — a late flush would otherwise re-append text to a card that
5640
+ * no longer exists.
5641
+ */
5642
+ deleteCard(card) {
5643
+ const id = card?.id
5644
+ if (typeof id !== 'string' || id === '') return false
5645
+ const controller = controllers.get(id)
5646
+ if (controller !== undefined) {
5647
+ controller.abort()
5648
+ void postJson('cancel', { id }).catch(() => {})
5649
+ }
5650
+ controllers.delete(id)
5651
+ pendingDeltas.delete(id)
5652
+ pendingReasonings.delete(id)
5653
+ // Every surface gets the same chance to close its own container as
5654
+ // it does on close(): an orphaned tab reads to the user as a card
5655
+ // that refused to die.
5656
+ if (card.surface === 'better-sidebar') {
5657
+ try {
5658
+ surfaces.better.close(card)
5659
+ } catch { /* the host may already be gone */ }
5660
+ } else if (card.surface === 'native-rightbar') {
5661
+ try {
5662
+ surfaces.native.close(card)
5663
+ } catch { /* the right rail owns its lifecycle */ }
5664
+ }
5665
+ const next = store.state.history
5666
+ .filter(item => item.id !== id)
5667
+ .slice(0, historyCap())
5668
+ // cap === 0 means "nothing is persisted": leave the stored key alone
5669
+ // rather than writing an empty payload over it (same rule as
5670
+ // syncPersisted).
5671
+ if (historyCap() > 0) writePersisted(next)
5672
+ store.set({
5673
+ history: next,
5674
+ cards: store.state.cards.filter(item => item.id !== id),
5675
+ })
5676
+ toast(t('cardDeleted'))
5677
+ return true
5678
+ },
5586
5679
  /**
5587
5680
  * Empty the durable history without touching running cards.
5588
5681
  *
@@ -6755,11 +6848,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6755
6848
 
6756
6849
  /**
6757
6850
  * 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.
6851
+ * dialog and the delete confirmation both live here because their host
6852
+ * mounts them only while open and the test shim has no working setState,
6853
+ * so a simulated click cannot reach them.
6761
6854
  */
6762
- components: Object.freeze({ FeedbackDialog }),
6855
+ components: Object.freeze({ FeedbackDialog, DeleteConfirmDialog }),
6763
6856
 
6764
6857
  /**
6765
6858
  * Test seam: pure helpers, frozen. The DSH client runtime only reads
package/index.js CHANGED
@@ -44,7 +44,7 @@ export const name = 'dsh-sidecard-ask'
44
44
  export const inject = ['webServer']
45
45
 
46
46
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
47
- export const PLUGIN_VERSION = '1.9.1'
47
+ export const PLUGIN_VERSION = '2.0.1'
48
48
 
49
49
  /** Route prefix of the plugin's own API. */
50
50
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -77,10 +77,12 @@ export const DEFAULT_CONFIG = {
77
77
  sideProvider: 'auto',
78
78
  /**
79
79
  * Floating-layer (A) surface. `capsule` = one-line pill that never covers
80
- * the conversation, `full` = the classic card stack, `off` = hidden behind a
81
- * small launcher. The geometry for all three lives in `client.js`:
82
- * the layer is anchored to the frame's right edge and never crosses the
83
- * conversation column's right boundary (see `FLOAT_*` there).
80
+ * the conversation, `full` = the classic card stack. (The former `off`
81
+ * "hidden behind a small launcher" shape was removed in 2.0.0; a stored
82
+ * `off` now fails validation and falls back to this default.) The geometry
83
+ * for both shapes lives in `client.js`: the layer is anchored to the frame's
84
+ * right edge and never crosses the conversation column's right boundary
85
+ * (see `FLOAT_*` there).
84
86
  */
85
87
  floatMode: 'capsule',
86
88
  /** Hard cap, in CSS px, for the floating layer's width. */
@@ -208,7 +210,7 @@ const ENUMS = {
208
210
  sideSurface: ['auto', 'native-rightbar', 'better-sidebar', 'flow'],
209
211
  captureZones: ['auto', 'chat', 'task', 'chat+task'],
210
212
  sideTools: ['readonly', 'inherit'],
211
- floatMode: ['capsule', 'full', 'off'],
213
+ floatMode: ['capsule', 'full'],
212
214
  answerStyle: ['concise', 'standard', 'detailed'],
213
215
  }
214
216
 
@@ -586,7 +588,7 @@ export function buildSidePrompt(input) {
586
588
  // 2. No dependency is added: the SMTP dialogue below runs on `node:net` and
587
589
  // `node:tls` only, because the Host half may import nothing but node:
588
590
  // built-ins (see the module header). A plugin that cannot reach an SMTP
589
- // relay still works: the Client offers a `mailto:` fallback.
591
+ // relay still works: the Client offers to copy the report text.
590
592
  // ────────────────────────────────────────────────────────────────────────────
591
593
 
592
594
  /** Bounds for one feedback mail, mirrored by the Client's own form checks. */
@@ -769,8 +771,7 @@ export function readSmtpFromEnv(env = process.env) {
769
771
  /**
770
772
  * Resolve the plaintext recipient. SERVER-SIDE ONLY: this value must never be
771
773
  * put into a response body or rendered by the Client — the settings page only
772
- * ever receives `toMasked` plus the `hasRecipient` flag, and asks for a finished
773
- * `mailto:` URL when the user actually clicks the mail-app button.
774
+ * ever receives `toMasked` plus the `hasRecipient` flag.
774
775
  * @param {Record<string, unknown>} config - the effective config.
775
776
  * @param {Record<string, string|undefined>} [env] - the process environment.
776
777
  * @returns {string} the address, or '' when unconfigured.
@@ -842,7 +843,7 @@ export function describeSmtpSettings(config = {}, env = process.env) {
842
843
  export function describeFeedback(config = {}, env = process.env) {
843
844
  const to = feedbackRecipient(config, env)
844
845
  const base = {
845
- transport: 'mailto',
846
+ transport: 'none',
846
847
  configured: false,
847
848
  hasRecipient: to !== '',
848
849
  toMasked: maskEmail(to),
@@ -854,7 +855,7 @@ export function describeFeedback(config = {}, env = process.env) {
854
855
  if (to === '') return { ...base, reason: '未配置收件邮箱:设置 feedbackAddress 或 DSH_SIDECARD_FEEDBACK_TO / DSH_CRB_FEEDBACK_TO' }
855
856
  if (!looksLikeEmail(to)) return { ...base, reason: '收件邮箱格式不合法' }
856
857
  const smtp = resolveSmtp(config, env)
857
- if (smtp.ok !== true) return { ...base, reason: `${smtp.reason}(可用“用邮件客户端发送”)` }
858
+ if (smtp.ok !== true) return { ...base, reason: smtp.reason }
858
859
  return { ...base, transport: 'smtp', configured: true, fromMasked: maskEmail(smtp.endpoint.from), reason: '' }
859
860
  }
860
861
 
@@ -920,26 +921,6 @@ export function describeEnvironment({ version, dshVersion = '', now = new Date()
920
921
  ].join('\n')
921
922
  }
922
923
 
923
- /**
924
- * Build the `mailto:` URL the mail-app button navigates to.
925
- *
926
- * Lives on the Host on purpose: the plaintext recipient must not be rendered or
927
- * held by the page. The Client asks for a finished URL and only follows it.
928
- * @returns {string} a `mailto:` URL with encoded subject and body.
929
- */
930
- export function buildMailtoUrl({ to, version, input, userAgent = '' }) {
931
- const subject = `[dsh-sidecard-ask ${version}] ${input.kind}: ${input.title}`
932
- const text = [
933
- `plugin: ${version}`,
934
- `kind: ${input.kind}`,
935
- input.body,
936
- '',
937
- `contact: ${input.contact === '' ? '(not provided)' : input.contact}`,
938
- ]
939
- if (userAgent !== '') text.push(`user agent: ${userAgent}`)
940
- return `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(text.join('\n'))}`
941
- }
942
-
943
924
  /**
944
925
  * Send-rate gate: one mail per `minGapMs`, at most `maxPerWindow` per window.
945
926
  * Every send is recorded only after it actually went out, so a relay outage
@@ -2092,8 +2073,8 @@ export function apply(ctx, patchConfig) {
2092
2073
  /** `/state` — the Client's single source of truth at boot and after a save. */
2093
2074
  const handleState = (res) => {
2094
2075
  // Two secrets never reach the page: the recipient stays server-side (the page
2095
- // gets `hasRecipient` and asks for a finished `mailto:` URL), and the SMTP
2096
- // password is write-only (the page only learns whether one is stored).
2076
+ // gets `hasRecipient` and `toMasked` only), and the SMTP password is
2077
+ // write-only (the page only learns whether one is stored).
2097
2078
  const publicConfig = { ...state.config }
2098
2079
  delete publicConfig.feedbackAddress
2099
2080
  delete publicConfig.feedbackSmtpPass
@@ -2151,9 +2132,8 @@ export function apply(ctx, patchConfig) {
2151
2132
  * `/feedback` — mail one settings-page report to the configured recipient.
2152
2133
  *
2153
2134
  * Failure codes are the Client's contract: `feedback-unconfigured` (no
2154
- * recipient or no SMTP — the Client then offers `mailto:`),
2155
- * `feedback-invalid` (bounds), `feedback-too-fast` / `feedback-too-many`
2156
- * (gate), `feedback-send-failed` (relay refused). The report body is never
2135
+ * recipient or no SMTP), `feedback-invalid` (bounds), `feedback-too-fast` /
2136
+ * `feedback-too-many` (gate), `feedback-send-failed` (relay refused). The report body is never
2157
2137
  * logged; only kind, masked recipient and outcome are.
2158
2138
  */
2159
2139
  const handleFeedback = async (req, res) => {
@@ -2208,44 +2188,6 @@ export function apply(ctx, patchConfig) {
2208
2188
  }
2209
2189
  }
2210
2190
 
2211
- /**
2212
- * `/feedback-mailto` — hand the Client a finished `mailto:` URL.
2213
- *
2214
- * This exists so the plaintext recipient never has to enter the page: the
2215
- * settings form shows a mask, and when the user clicks "send with my mail app"
2216
- * the Host builds the URL and the Client only navigates to it.
2217
- */
2218
- const handleFeedbackMailto = async (req, res) => {
2219
- let body
2220
- try {
2221
- body = await readJsonBody(req)
2222
- } catch (error) {
2223
- writeError(res, 400, toWireError(error, 'bad-request').code, toWireError(error).message)
2224
- return
2225
- }
2226
- const input = sanitizeFeedbackInput(body)
2227
- if (input.ok !== true) {
2228
- writeError(res, 400, 'feedback-invalid', input.message)
2229
- return
2230
- }
2231
- const to = feedbackRecipient(state.config)
2232
- if (to === '') {
2233
- writeError(res, 503, 'feedback-unconfigured', '未配置收件邮箱:在设置页填写,或设置 DSH_SIDECARD_FEEDBACK_TO')
2234
- return
2235
- }
2236
- if (!looksLikeEmail(to)) {
2237
- writeError(res, 503, 'feedback-unconfigured', '收件邮箱格式不合法')
2238
- return
2239
- }
2240
- const url = buildMailtoUrl({
2241
- to,
2242
- version: PLUGIN_VERSION,
2243
- input: input.value,
2244
- userAgent: typeof body.userAgent === 'string' ? body.userAgent : '',
2245
- })
2246
- writeOk(res, { url, toMasked: maskEmail(to) })
2247
- }
2248
-
2249
2191
  /** `/ask` — one SSE stream per side answer. */
2250
2192
  const handleAsk = async (req, res) => {
2251
2193
  let body
@@ -2338,7 +2280,7 @@ export function apply(ctx, patchConfig) {
2338
2280
  * The HTTP methods the plugin serves; kept in one place because the route
2339
2281
  * registration has two shapes (see below).
2340
2282
  */
2341
- const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback', 'feedback-mailto']
2283
+ const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
2342
2284
 
2343
2285
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2344
2286
  const routeHandler = async (req, res) => {
@@ -2373,7 +2315,6 @@ export function apply(ctx, patchConfig) {
2373
2315
  else if (method === 'reset') handleReset(req, res)
2374
2316
  else if (method === 'diagnose') await handleDiagnose(req, res)
2375
2317
  else if (method === 'feedback') await handleFeedback(req, res)
2376
- else if (method === 'feedback-mailto') await handleFeedbackMailto(req, res)
2377
2318
  else writeError(res, 404, 'not-found', `未知接口 "${method}"`)
2378
2319
  } catch (error) {
2379
2320
  const wire = toWireError(error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.9.1",
3
+ "version": "2.0.1",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",