dsh-sidecard-ask 2.1.2 → 2.1.4

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,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.4
4
+
5
+ **主题:把「技术方案评审团」查出的 11 条缺陷一次修完。四套脚本全绿,新增 21 条回归断言。**
6
+ 排查依据:Software Architect / Application Security Engineer / Reality Checker 三方评审 + 源码复核 + 实测复现。
7
+
8
+ ### 修复
9
+
10
+ - **B1(阻断,静默数据丢失)关闭的卡片在下次答题完成时被抹掉**。`syncPersisted()` 只从
11
+ 「活跃卡片」重建整个持久化集合再整键覆盖,而 `close()` 只把卡片移出活跃区、durable 快照
12
+ 仍留在 `store.state.history` 里,于是关掉的那张被静默删除(`contract-test` 原先把破坏后的
13
+ 结果当成了期望值)。现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目,按 id 去重(活跃方
14
+ 优先)后按 newest-first 截到 cap。删除路径本来就先改 `store.history` 再写盘,并集不会把
15
+ 它们删掉的条目复活。
16
+ - **B2(高,虚假安全保证)`agentPreset` 在 0.2.1-alpha.2+ 的 activation 面没被转发**。
17
+ `startSubagent` 只在旧 `start(provider, request)` 面转发 `agentPreset`,新面的 `request`
18
+ 漏了它 → 子代理拿默认全套工具,而 start 帧仍按配置上报 `toolFilter: 'preset'`,卡片于是
19
+ 对用户谎报「工具集由 preset 决定」。现在两个面都转发。
20
+ - **B3(中高,降级链断裂)Rung 1 `sessions.using()` 没有 try/catch**。它一抛(会话归档、
21
+ 形状漂移,或它内部的 `no-session-face`),整条「改发到主对话」就断在第一档,后面
22
+ `retain` / `inputActions` / `draft` 三档全部不可达。现在与 Rung 2 对称降级,
23
+ `prompt-rejected` 仍照旧抛给调用方(那是用户可见的拒绝,不是通道缺失)。
24
+ - **B4(中,原生栏串卡)第二张卡开进原生右栏时第一张没有迁回浮层**。它只是被盖住——卡片
25
+ 仍标 `native-rightbar`、store 里被过滤掉,流式输出继续往看不见的卡片里写,且 rail 上那张
26
+ 永远关不掉。现在 `open()` 先把栏里其它卡片迁回 `flow`;`close()` 增加归属校验(栏被另一
27
+ 张卡占用时不替它关)与 `contentId` 行匹配(多行时关「属于这张卡」的那行);`openTab`
28
+ 抛错时回滚 `cardId`,避免 CardHost 去渲染一张根本没有 tab 的卡。
29
+ - **B5(中低)`/cancel` 把跨会话的全局 run id 列表回给页面**。`runs` 是跨会话的全局表,
30
+ 回 `active` 等于泄露别的会话正在追问的卡片 id(`cancel` 本身也不校验归属),而 Client
31
+ 从不消费它。现在只回 `{ cancelled }`。
32
+ - **B6(中低,迁移被永久挡住)空的 v3 信封会阻断 v1/v2 迁移**。`raw !== null` 就直接
33
+ return,损坏的 JSON 或旧版本留下的空信封同样挡住。现在写盘时带 `writtenAt` 标记,只有
34
+ 「本版本自己写的空信封」才算权威(用户确实清空了历史,绝不从 v2/v1 复活),没有该标记的
35
+ 空信封与损坏 JSON 继续向下探测。
36
+ - **B7(低,卸载泄漏)`dispose()` 只清了 `streamListeners`,漏了 `chunkListeners`**。第二个
37
+ 流来源的订阅者会留下仍被 `session/event` 桥持有的闭包(它捕获整个 engine 状态)。
38
+ - **B9(低,死重量)持久化写死 `slice(-12)`,而 `followUp` 只发 6 轮**。改成
39
+ `HISTORY_TURNS`,与上下文预算同一个上限。
40
+ - **B10(低,死代码)`let finished = false` 只赋值不读取**,删除。
41
+ - **B11(低,可诊断性)信任围栏的 403 只说 `forbidden`**。现在说明「Host 不在信任范围 +
42
+ 怎么放行(127.0.0.1 或 `trustedHosts`)」。有意**不**自动信任绑定地址:插件读不到宿主的
43
+ bind 配置,猜一个就等于自己拆掉 DNS-rebinding 防线。
44
+ - **B8(条件性,平台限制)配置里的 SMTP 密码以明文落在 `~/.dsh/` 下的用户层 JSON**。
45
+ 写盘已带 `mode: 0o600`(POSIX 生效,Windows 忽略),代码注释与 README 均已注明;
46
+ 无进程内可做的进一步缓解,**保留为已文档化的限制**,建议优先用环境变量。
47
+
48
+ ### 验证
49
+
50
+ `node test/verify.mjs` / `contract-test.mjs` / `float-placement.mjs` / `smoke-test.mjs`:
51
+ **57 / 1446 / 100 / 277 全绿(1880 条,0 失败)**。相对修复前 1859 条新增 21 条回归断言,
52
+ 逐条对应上面的缺陷:关卡片后历史仍在(contract)、activation 面转发 preset 且无能力时
53
+ 不带该字段(smoke)、`using()` 抛错降级而 `prompt-rejected` 仍抛(contract)、空/损坏 v3
54
+ 信封不挡迁移且自有空信封不被复活(contract)、原生栏单地址与关 tab 归属(contract)、
55
+ `/cancel` 不泄露 `active`(smoke)。
56
+
57
+ ## 2.1.3
58
+
59
+ **主题:折叠态胶囊不再压在聊天输入框上(2026-10-25 实拍报告)。**
60
+
61
+ ### 修复
62
+
63
+ - **胶囊盖住输入框**。浮层固定在 `bottom:16px`,而输入框就在会话列底部,折叠态胶囊
64
+ 正好落在输入框上。新增纯函数
65
+ `floatBottom({ frameHeight, composerTop, fallback, limit })`:测量 `[data-composer-card]`
66
+ 的顶边,把浮层抬到输入框上方 8px(CSS 变量 `--dsa-gap-bottom`)。两道保险——输入框
67
+ 顶边落在窗口上半部(空会话的居中输入框)时不抬;结果上限 240px,避免超高输入框把
68
+ 浮层推到会话正文上。
69
+ - **胶囊改为按内容收缩**。浮层本就贴着右边缘(`right:8px`),「再往右」没有余量,所以
70
+ 改为缩窄:内容自适应、上限 240px、容器右对齐,左边缘随之右退。内边距 8/12 → 6/10、
71
+ 行高 18 → 16,问题文本由 `flex:1` 改为 `flex:0 1 auto`(只在真放不下时才截断)。
72
+ 取舍:抬到输入框上方后它会压住最后一行消息,所以必须足够窄小。
73
+ - 新增 `node.dataset.layerBottom`(与 `layerWidth` 并列),用户反馈可以直接报出浮层
74
+ 离底多少。
75
+
76
+ ### 验证
77
+
78
+ `test/float-placement.mjs` 新增 `floatBottom` 数值用例与源码钩子;四套脚本
79
+ 100 / 1427 / 57 / 271 全绿。
80
+
3
81
  ## 2.1.2
4
82
 
5
83
  **主题:按 DSH 0.2.1-rc.3 的实测契约修复——原生右栏卡片、语言跟随、只读工具收窄、可信主机围栏。
package/README.md CHANGED
@@ -196,7 +196,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
196
196
 
197
197
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
198
198
 
199
- ### 4.1 内置浮层的位置规则(`floatPlacement`)
199
+ ### 4.1 内置浮层的位置规则(`floatPlacement` / `floatBottom`)
200
200
 
201
201
  用户实测报告过一个问题:浮层是 `position:fixed; right:16px; width:min(400px,92vw)` 的实心卡片。
202
202
  实测那一次(1400px 宽的窗口)对话区右边界在 x≈971,而浮层左边界在 x≈858,**压住正文约 113px**,
@@ -218,7 +218,23 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
218
218
  - 浮层**只使用对话区右侧的预留带**(右栏也在这条带里),窗口越窄它越窄,而不是越靠左。
219
219
  - `capped: true` 表示「上限没用满,是被可用带宽压住的」——这是正常的自适应,不是错误。
220
220
  - 挂载时、窗口 resize 时、右栏开合时(`ResizeObserver`)都会重算;流式刷新只重渲染,不动几何。
221
- - 浮层根节点带 `data-layer-width`(实际宽度)与 `data-dsa-float-mode`:报告问题时可直接读这两个值。
221
+ - 浮层根节点带 `data-layer-width`(实际宽度)、`data-layer-bottom`(距窗口底的距离)与
222
+ `data-dsa-float-mode`:报告问题时可直接读这三个值。
223
+
224
+ **垂直位置(2.1.3)**:宽度不是唯一的问题——浮层原本固定在 `bottom:16px`,而输入框就在会话列底部,
225
+ 折叠态胶囊必然压在输入框上(2026-10-25 实拍报告「挡住聊天框了」)。垂直位置由纯函数 `floatBottom` 决定:
226
+
227
+ ```js
228
+ // floatBottom({ frameHeight, composerTop = null, fallback = 16, limit = 240 })
229
+ if (composerTop 未知 || 帧高为 0) return 16 // 测不到就保持旧锚点
230
+ if (composerTop < 帧高 / 2) return 16 // 居中输入框(空会话)不是底栏,不抬
231
+ bottom = clamp(帧高 - composerTop + 8, 16, 240) // 抬到输入框上方 8px,上限 240px
232
+ ```
233
+
234
+ `composerTop` 取自 DSH 的 `[data-composer-card]`(输入框那张卡片的顶边),该节点也进了
235
+ `ResizeObserver`——输入多行时卡片会往上长,浮层跟着重算,不会重新沉回输入框上。
236
+ 取舍要讲清楚:抬起来之后它**改压最后一两行消息**,所以胶囊同时改成按内容收缩(≤240px、右对齐),
237
+ 尽量把覆盖面积压到最小。
222
238
 
223
239
  ### 4.2 右侧栏打开时「占满它」(`dockRail`,1.8.3)
224
240
 
@@ -782,10 +798,12 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
782
798
  展开后标题栏右侧有**收起为胶囊(⇥)**与**删除这条追问对话(×)**——× 会把该卡片从浮层与追问历史里
783
799
  一并移除,**无法撤销**(有内容时会先弹插件内确认层;点浮层底部的「关闭」则只把它摘离浮层、历史仍保留在
784
800
  设置页的历史管理器里)。2.0.0 起不再有「隐藏整个浮层 / 只留一个计数圆点」这套形态。
785
- 用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
801
+ 用 F12 选中浮层根节点(`[data-dsa-root]`)读三个值自检:
786
802
  - `data-dsa-float-mode` 应为 `capsule` / `full`(铺满右栏时为 `dock`);
787
- - `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`。
788
- **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
803
+ - `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`;
804
+ - `data-layer-bottom` 应等于 `帧高 - 输入框(`[data-composer-card]`)顶边 + 8`(空会话/测不到时为 16)。
805
+ **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界,
806
+ 下边缘不得落在输入框上**。
789
807
  把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
790
808
  只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
791
809
  9. **占满右侧栏(1.8.3,`dockRail`)**:先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 →
package/client.js CHANGED
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  * the Host and the module's `api.version` both read it, so a report can
118
118
  * never claim a generation the browser is not actually running.
119
119
  */
120
- const CLIENT_VERSION = '2.1.2'
120
+ const CLIENT_VERSION = '2.1.4'
121
121
 
122
122
  /**
123
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -364,6 +364,17 @@ window.__ModuleLoader__.load({
364
364
  const FLOAT_GAP_SIDEBAR = 10
365
365
  const FLOAT_MIN_WIDTH = 240
366
366
  const FLOAT_MAX_WIDTH_LIMIT = 560
367
+ /**
368
+ * Vertical clearance (2.1.3). The layer used to be pinned `bottom:16px`,
369
+ * i.e. straight on top of the composer — which is what the 2026-10-25
370
+ * report ("挡住聊天框了") caught: the capsule covered the input box. The
371
+ * layer now measures the composer and sits just above it.
372
+ */
373
+ const FLOAT_GAP_BOTTOM = 16
374
+ const FLOAT_GAP_COMPOSER = 8
375
+ const FLOAT_BOTTOM_LIMIT = 240
376
+ /** The capsule is a status pill, not a card: it sizes to its content. */
377
+ const CAPSULE_MAX_WIDTH = 240
367
378
 
368
379
  /**
369
380
  * The floating layer's width for one frame.
@@ -394,6 +405,40 @@ window.__ModuleLoader__.load({
394
405
  return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
395
406
  }
396
407
 
408
+ /**
409
+ * How far above the frame's bottom edge the floating layer sits (2.1.3).
410
+ *
411
+ * Pure, like {@link floatPlacement}, and mirrored by the tests.
412
+ * `composerTop` is the composer card's top edge, in frame coordinates. The
413
+ * composer is part of the conversation column, so the old fixed
414
+ * `bottom:16px` parked the layer straight on top of the input box; the
415
+ * layer is lifted until its own bottom edge clears the composer by
416
+ * `FLOAT_GAP_COMPOSER`.
417
+ *
418
+ * Two guards keep that lift honest: a composer whose top sits in the frame's
419
+ * upper half is not a bottom bar (the empty-session composer is vertically
420
+ * centred) and is ignored, and the result is clamped to `limit` so an
421
+ * unusually tall composer cannot push the layer over the conversation.
422
+ *
423
+ * @param {object} input - `frameHeight`, `composerTop`, `fallback`, `limit`.
424
+ * @returns {number} Bottom offset in px, never below `fallback`.
425
+ */
426
+ function floatBottom({
427
+ frameHeight,
428
+ composerTop = null,
429
+ fallback = FLOAT_GAP_BOTTOM,
430
+ limit = FLOAT_BOTTOM_LIMIT,
431
+ }) {
432
+ const base = Number.isFinite(fallback) ? Math.max(0, Math.round(fallback)) : FLOAT_GAP_BOTTOM
433
+ const frame = Number.isFinite(frameHeight) ? frameHeight : 0
434
+ if (frame <= 0 || !Number.isFinite(composerTop)) return base
435
+ // A centred composer is not a bottom bar; leave the layer where it was.
436
+ if (composerTop < frame / 2) return base
437
+ const clear = Math.round(frame - composerTop + FLOAT_GAP_COMPOSER)
438
+ const cap = Number.isFinite(limit) ? Math.max(base, limit) : FLOAT_BOTTOM_LIMIT
439
+ return Math.max(base, Math.min(clear, cap))
440
+ }
441
+
397
442
  const DOCK_MIN_WIDTH = 280
398
443
  const DOCK_MIN_HEIGHT = 240
399
444
 
@@ -2133,16 +2178,26 @@ window.__ModuleLoader__.load({
2133
2178
  }
2134
2179
 
2135
2180
  // Rung 1 — `using()`: the documented helper, present from 0.1.6-alpha.2.
2181
+ // 与 Rung 2 必须对称地包 try/catch:`using()` 本身(或它内部的
2182
+ // `no-session-face` 抛出)一失败就该降级到 retain → inputActions →
2183
+ // draft,而不是让整个"改发到主对话"断在这里 —— 后两档存在的理由
2184
+ // 正是这种情况。`prompt-rejected` 仍然照旧抛给调用方(那是用户可见的
2185
+ // 拒绝,不是通道缺失)。
2136
2186
  if (sessionId !== null && typeof sessions?.using === 'function') {
2137
- const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
2138
- const face = reference?.binding?.session
2139
- if (face === undefined || typeof face.prompt !== 'function') {
2140
- throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
2141
- }
2142
- return face.prompt(parts, 'queue')
2143
- })
2144
- accepted(result)
2145
- return { mode: 'session', via: 'sessions.using' }
2187
+ try {
2188
+ const result = await sessions.using(sessionId, { source: 'gateway' }, async (reference) => {
2189
+ const face = reference?.binding?.session
2190
+ if (face === undefined || typeof face.prompt !== 'function') {
2191
+ throw Object.assign(new Error('session face has no prompt()'), { code: 'no-session-face' })
2192
+ }
2193
+ return face.prompt(parts, 'queue')
2194
+ })
2195
+ accepted(result)
2196
+ return { mode: 'session', via: 'sessions.using' }
2197
+ } catch (error) {
2198
+ if (error?.code === 'prompt-rejected') throw error
2199
+ console.warn(`[${PLUGIN_ID}] sessions.using 提交失败,尝试下一级:`, error)
2200
+ }
2146
2201
  }
2147
2202
 
2148
2203
  // Rung 2 — `retain()` + explicit release: the same operation spelled out,
@@ -2262,8 +2317,22 @@ window.__ModuleLoader__.load({
2262
2317
  cardId: null,
2263
2318
  open(card) {
2264
2319
  if (!this.available() || typeof this.controller.openTab !== 'function') return false
2320
+ // 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
2321
+ // 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
2322
+ // 继续往一张看不见的卡片里写。
2323
+ for (const other of store.state.cards) {
2324
+ if (other.id !== card.id && other.surface === 'native-rightbar') {
2325
+ patchCard(other.id, { surface: 'flow' })
2326
+ }
2327
+ }
2265
2328
  this.cardId = card.id
2266
- this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2329
+ try {
2330
+ this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2331
+ } catch (error) {
2332
+ // 开栏失败就把地址回滚,免得 CardHost 去渲染一张根本没有 tab 的卡。
2333
+ this.cardId = null
2334
+ throw error
2335
+ }
2267
2336
  return true
2268
2337
  },
2269
2338
  close(card) {
@@ -2274,6 +2343,10 @@ window.__ModuleLoader__.load({
2274
2343
  // and lets CardHost show its closed-card face.
2275
2344
  const controller = this.controller
2276
2345
  if (controller === null || controller === undefined) return false
2346
+ // 归属校验:栏里那条同 kind 的 tab 若正被另一张卡片占用,就不能替它
2347
+ // 关掉(否则关 A 会把 B 的 tab 一起关掉)。cardId === null 是"清理
2348
+ // 残留 tab"的路径,此时按 kind 关是对的。
2349
+ if (this.cardId !== null && this.cardId !== card.id) return false
2277
2350
  let rows = []
2278
2351
  try {
2279
2352
  rows = typeof controller.openTabs?.getSnapshot === 'function'
@@ -2283,6 +2356,12 @@ window.__ModuleLoader__.load({
2283
2356
  const row = Array.isArray(rows)
2284
2357
  ? rows.find(entry => entry?.kind === CARD_KIND
2285
2358
  && typeof entry?.tabId === 'string' && entry.tabId !== ''
2359
+ // 单地址模型下这里只该有一行;万一宿主保留了多行,必须关"属于这张
2360
+ // 卡片"的那一行(openTab 把 cardId 写进 params → contentId),
2361
+ // 否则会替另一张卡片关掉它的 tab。
2362
+ && (typeof entry?.contentId !== 'string'
2363
+ || entry.contentId === ''
2364
+ || entry.contentId === card.id)
2286
2365
  && (typeof entry?.sessionId !== 'string'
2287
2366
  || store.state.sessionId === null
2288
2367
  || entry.sessionId === store.state.sessionId))
@@ -2654,9 +2733,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2654
2733
  \`floatPlacement\` through --dsa-float-w, so it grows left only as far as the
2655
2734
  conversation column allows. pointer-events stays off on the container so the
2656
2735
  empty area around the pill never eats a click meant for the conversation. */
2657
- .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
2736
+ .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:var(--dsa-gap-bottom, 16px);
2658
2737
  display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
2659
2738
  .dsa-float > *{pointer-events:auto}
2739
+ /* Capsule mode (2.1.3): the layer is a status pill, not a card. It sizes to its
2740
+ own content and stays right-aligned, so shrinking it is what pulls its left
2741
+ edge back over the conversation — the layer is already flush with the frame's
2742
+ right edge, so it has nowhere further right to go. */
2743
+ .dsa-float[data-dsa-float-mode="capsule"]{width:auto;align-items:flex-end;
2744
+ max-width:min(var(--dsa-float-w, 340px), var(--dsa-capsule-max, 240px), 92vw)}
2660
2745
  /* Docked presentation (1.8.3): the same container, but pinned to the right
2661
2746
  rail's measured rectangle (inline left/top/width/height) instead of the
2662
2747
  frame's bottom-right corner. Right/bottom must be released, otherwise the
@@ -2671,8 +2756,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2671
2756
  box-shadow:none;background:transparent;padding:10px 12px 8px}
2672
2757
  .dsa-float[data-dsa-float-mode="dock"] .dsa-answer{max-height:none;flex:1 1 auto;min-height:0;overflow:auto}
2673
2758
  .dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
2674
- .dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
2675
- padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
2759
+ .dsa-capsule{display:flex;align-items:center;gap:6px;width:auto;max-width:100%;box-sizing:border-box;text-align:left;
2760
+ padding:6px 10px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:16px;
2676
2761
  border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
2677
2762
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
2678
2763
  box-shadow:0 2px 10px rgba(0,0,0,.14)}
@@ -2685,7 +2770,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2685
2770
  .dsa-capsule-dot[data-status="error"]{background:var(--dsw-alias-state-error-primary, #c0392b)}
2686
2771
  .dsa-capsule-dot[data-status="stopped"]{background:var(--dsw-alias-state-warn-primary, #b8860b)}
2687
2772
  .dsa-capsule-label{font-weight:600;white-space:nowrap}
2688
- .dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
2773
+ .dsa-capsule-q{min-width:0;flex:0 1 auto;color:var(--dsw-alias-label-secondary, #666);
2689
2774
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2690
2775
  .dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
2691
2776
  /* Delete confirmation (2.0.0). Fixed, not inline: a card docked into the right
@@ -4707,7 +4792,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4707
4792
  // Slot subtrees only: `.rightbar` and `[data-dsh-panel-host]` were
4708
4793
  // dropped in 2.1.2 — no supported build renders either marker, so they
4709
4794
  // were two dead branches in a selector that runs on every resize.
4710
- const selector = '[data-slot^="conversation"], [data-slot^="sidebar.right"]'
4795
+ // 2.1.3: the composer card joins the list so a multi-line draft (which
4796
+ // grows the box upward) re-measures the layer's vertical clearance
4797
+ // instead of letting the pill sink back onto the input.
4798
+ const selector = '[data-slot^="conversation"], [data-slot^="sidebar.right"], [data-composer-card]'
4711
4799
  const observed = new Set()
4712
4800
  const all = () => {
4713
4801
  try {
@@ -4743,11 +4831,25 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4743
4831
  const railBox = rail === null ? null : rail.box
4744
4832
  const laneRight = laneRect === null ? rootWidth : laneRect.right
4745
4833
  const railLeft = railBox === null ? null : railBox.left
4834
+ // 2.1.3: the composer occupies the bottom of the conversation column,
4835
+ // so the layer's old fixed `bottom:16px` landed straight on top of the
4836
+ // input box. Measure the composer card (the box the user types in) and
4837
+ // lift the layer clear of it; `floatBottom` owns the guards.
4838
+ const composerEl = one('[data-slot^="conversation"] [data-composer-card]')
4839
+ ?? one('[data-composer-card]')
4840
+ const composerRect = measure(composerEl)
4841
+ const gapBottom = floatBottom({
4842
+ frameHeight: rootHeight,
4843
+ composerTop: composerRect === null ? null : composerRect.top,
4844
+ })
4845
+ node.style.setProperty('--dsa-gap-bottom', `${gapBottom}px`)
4746
4846
  const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
4747
4847
  node.style.setProperty('--dsa-float-w', `${placement.width}px`)
4748
4848
  node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
4849
+ node.style.setProperty('--dsa-capsule-max', `${CAPSULE_MAX_WIDTH}px`)
4749
4850
  // Readable placement, so a user report can say WHERE the layer sat.
4750
4851
  node.dataset.layerWidth = String(placement.width)
4852
+ node.dataset.layerBottom = String(gapBottom)
4751
4853
 
4752
4854
  // Docked presentation: the card fills the rail page's body instead of
4753
4855
  // floating over it. Only measured in `full` mode — a capsule/launcher
@@ -5773,13 +5875,32 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5773
5875
  } catch { return [] }
5774
5876
  }
5775
5877
 
5878
+ /** 是否是「本版本自己写下的空信封」(带 writtenAt 标记)。 */
5879
+ function isOwnedEmptyEnvelope(raw) {
5880
+ try {
5881
+ const value = JSON.parse(raw)
5882
+ return value !== null
5883
+ && typeof value === 'object'
5884
+ && value.version === 3
5885
+ && Array.isArray(value.cards)
5886
+ && Number.isFinite(value.writtenAt)
5887
+ } catch {
5888
+ return false
5889
+ }
5890
+ }
5891
+
5776
5892
  function readPersisted() {
5777
5893
  let raw = null
5778
5894
  try {
5779
5895
  raw = window.localStorage?.getItem(PERSIST_KEY)
5780
5896
  } catch { return [] }
5781
5897
  const v3 = parseEnvelope(raw, 3).slice(0, 50)
5782
- if (v3.length > 0 || (raw !== null && raw !== undefined)) return v3
5898
+ if (v3.length > 0) return v3
5899
+ // 空的 v3 信封只有在「本版本自己写的」时候才算权威:它带 writtenAt,
5900
+ // 说明用户确实把历史清空了,此时绝不能回落到 v2/v1 把旧记录复活。
5901
+ // 而没有 writtenAt 的空信封(旧版本写的 / 迁移中途留下的)或损坏的
5902
+ // JSON 必须继续向下探测 —— 否则 v1、v2 的数据会被永久挡住。
5903
+ if (raw !== null && raw !== undefined && isOwnedEmptyEnvelope(raw)) return v3
5783
5904
  // v2 → v3: v2 entries have no `versions`, so the field is seeded empty.
5784
5905
  let v2raw = null
5785
5906
  try {
@@ -5813,7 +5934,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5813
5934
  let lastError = null
5814
5935
  for (;;) {
5815
5936
  try {
5816
- window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining }))
5937
+ window.localStorage?.setItem(PERSIST_KEY, JSON.stringify({ version: 3, cards: remaining, writtenAt: Date.now() }))
5817
5938
  return remaining
5818
5939
  } catch (error) {
5819
5940
  lastError = error
@@ -5839,7 +5960,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5839
5960
  .filter(card => card.carrier === 'side'
5840
5961
  && (card.status === 'done' || card.status === 'stopped')
5841
5962
  && card.text !== '')
5842
- .slice(0, cap)
5843
5963
  .map(card => ({
5844
5964
  id: card.id,
5845
5965
  question: card.question,
@@ -5855,7 +5975,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5855
5975
  createdAt: Number.isFinite(card.createdAt) ? card.createdAt : Date.now(),
5856
5976
  provider: typeof card.provider === 'string' ? card.provider : '',
5857
5977
  elapsedMs: Number.isFinite(card.elapsedMs) ? card.elapsedMs : null,
5858
- history: Array.isArray(card.history) ? card.history.slice(-12) : [],
5978
+ // 持久化的历史轮次与上下文预算同一个上限(HISTORY_TURNS):
5979
+ // 这里曾经写死 `slice(-12)`,而 followUp 只发 6 轮,多出来的
5980
+ // 6 轮永远不会被读,是纯死重量。
5981
+ history: Array.isArray(card.history) ? card.history.slice(-HISTORY_TURNS) : [],
5859
5982
  // P2-2: kept so a replayed rewrite card still offers "insert".
5860
5983
  actionId: typeof card.actionId === 'string' ? card.actionId.slice(0, 60) : null,
5861
5984
  // P2-5b: bounded, text-capped older answers. `question` is short
@@ -5879,7 +6002,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5879
6002
  store.set({ history: [] })
5880
6003
  return
5881
6004
  }
5882
- const persisted = writePersisted(finished)
6005
+ // #6(2026-10-26 修复):关闭一张卡片只把它从「活跃区」移除,
6006
+ // durable 快照仍然留在 store.history 里。这里过去只从活跃卡片
6007
+ // 重建整个集合再整键覆盖,于是「关掉的那张」会在下一次答题完成
6008
+ // 时被静默抹掉(contract-test 把破坏后的结果当成了期望)。
6009
+ // 现在写的是并集:活跃卡片 ∪ 尚未关闭的历史条目(按 id 去重,
6010
+ // 活跃方优先),再按 newest-first 截到 cap。
6011
+ // 删除路径(deleteCard / deleteHistoryCard / 改投主对话 /
6012
+ // clearHistory)都是先改 store.history 再写盘,所以并集不会把它们
6013
+ // 删掉的条目复活。
6014
+ const liveIds = new Set(finished.map(entry => entry.id))
6015
+ const kept = store.state.history.filter(entry => entry !== null
6016
+ && typeof entry === 'object'
6017
+ && typeof entry.id === 'string'
6018
+ && !liveIds.has(entry.id))
6019
+ const merged = [...finished, ...kept]
6020
+ .sort((left, right) => (Number(right.createdAt) || 0) - (Number(left.createdAt) || 0))
6021
+ .slice(0, cap)
6022
+ const persisted = writePersisted(merged)
5883
6023
  store.set({ history: persisted })
5884
6024
  }
5885
6025
 
@@ -7429,6 +7569,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7429
7569
  renderRichText,
7430
7570
  pickSurface,
7431
7571
  floatPlacement,
7572
+ floatBottom,
7432
7573
  railDock,
7433
7574
  compareVersions,
7434
7575
  electFamilyLeader,
package/index.js CHANGED
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
45
45
  export const inject = ['webServer']
46
46
 
47
47
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
48
- export const PLUGIN_VERSION = '2.1.2'
48
+ export const PLUGIN_VERSION = '2.1.4'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -1541,7 +1541,7 @@ function openSse(res) {
1541
1541
  * Build the engine that owns every side-card run.
1542
1542
  * @param {import('@deepseek-ai/cordis').Context} ctx - host plugin context.
1543
1543
  * @param {() => Record<string, unknown>} configOf - live config reader.
1544
- * @returns {{ask: Function, cancel: Function, activeIds: Function, dispose: Function, capabilities: Function}}
1544
+ * @returns {{ask: Function, cancel: Function, dispose: Function, capabilities: Function}}
1545
1545
  */
1546
1546
  function createSideEngine(ctx, configOf) {
1547
1547
  /** id → run record. */
@@ -1630,6 +1630,11 @@ function createSideEngine(ctx, configOf) {
1630
1630
  parent: fields.parent,
1631
1631
  ...(fields.persona === undefined ? {} : { persona: fields.persona }),
1632
1632
  ...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
1633
+ // 0.2.1-alpha.2+ 的 `request` 与旧 `start(provider, request)` 是同一个
1634
+ // SubagentStartRequest 形状,`agentPreset` 必须一起转发。漏掉这一项
1635
+ // 会让子代理拿到默认全套工具,而 start 帧仍按配置上报
1636
+ // `toolFilter: 'preset'`,卡片于是对用户谎报"工具集由 preset 决定"。
1637
+ ...(fields.agentPreset === undefined ? {} : { agentPreset: fields.agentPreset }),
1633
1638
  },
1634
1639
  })
1635
1640
  return {
@@ -1893,7 +1898,6 @@ function createSideEngine(ctx, configOf) {
1893
1898
  /** Which source produced deltas: process-local frames or durable chunks. */
1894
1899
  let framesSeen = false
1895
1900
  let chunksSeen = false
1896
- let finished = false
1897
1901
 
1898
1902
  /** Detach this run's stream listener (frames are per attempt). */
1899
1903
  let detach = () => {}
@@ -2078,7 +2082,6 @@ function createSideEngine(ctx, configOf) {
2078
2082
  })
2079
2083
  return
2080
2084
  }
2081
- finished = true
2082
2085
  sink.send('done', {
2083
2086
  id,
2084
2087
  text,
@@ -2140,9 +2143,12 @@ function createSideEngine(ctx, configOf) {
2140
2143
  }
2141
2144
  runs.clear()
2142
2145
  streamListeners.clear()
2146
+ // 第二个流来源的订阅者也要清:只清 streamListeners 会在插件卸载后
2147
+ // 留下仍被 `session/event` 桥持有的闭包(它捕获了整个 engine 状态)。
2148
+ chunkListeners.clear()
2143
2149
  }
2144
2150
 
2145
- return { ask, cancel, dispose, capabilities, activeIds: () => [...runs.keys()] }
2151
+ return { ask, cancel, dispose, capabilities }
2146
2152
  }
2147
2153
 
2148
2154
  // ────────────────────────────────────────────────────────────────────────────
@@ -2594,7 +2600,10 @@ export function apply(ctx, patchConfig) {
2594
2600
  writeError(res, 400, 'bad-request', '缺少 id')
2595
2601
  return
2596
2602
  }
2597
- writeOk(res, { cancelled: engine.cancel(id), active: engine.activeIds() })
2603
+ // 只回本次取消的结果:`runs` 是跨会话的全局表,把它的 id 列表回给
2604
+ // 任意可信页面等于泄露别的会话正在追问的卡片 id(而 cancel 本身也不
2605
+ // 校验归属),Client 侧也没有任何地方消费这个字段。
2606
+ writeOk(res, { cancelled: engine.cancel(id) })
2598
2607
  }
2599
2608
 
2600
2609
  /** `/config` — merge a partial config into the user layer and persist it. */
@@ -2707,7 +2716,16 @@ export function apply(ctx, patchConfig) {
2707
2716
  /** One request → one response; the dispatcher owns path parsing and the fence. */
2708
2717
  const routeHandler = async (req, res) => {
2709
2718
  if (!isTrustedRequest(req, trustedHostsOf())) {
2710
- writeError(res, 403, 'forbidden', 'forbidden')
2719
+ // 说明"为什么被拒 + 怎么放行":这条 403 只会出现在 DSH 被绑到非回环
2720
+ // 地址、或经反向代理访问时(Host 既不是 loopback,也不在 trustedHosts
2721
+ // 里)。有意不自动信任绑定地址:插件读不到宿主的 bind 配置,猜一个就等于
2722
+ // 自己把 DNS-rebinding 防线拆掉。
2723
+ writeError(
2724
+ res,
2725
+ 403,
2726
+ 'forbidden',
2727
+ 'Host 不在信任范围:请用 http://127.0.0.1:<端口> 访问,或把该地址写进 trustedHosts 配置',
2728
+ )
2711
2729
  return
2712
2730
  }
2713
2731
  // Path parsing runs BEFORE the session gate now, because `ping` must be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",