dsh-sidecard-ask 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ **主题:浮层不再压住主对话——「A 胶囊 + C 右栏」定案,一个算宽度的纯函数 + 一个默认收起的形态。**
6
+
7
+ 用户实测反馈(截图:原文被卡片尾部盖住):右下角卡片从 `right:16px` 往左固定铺 400px,
8
+ 截图上(1400px 宽的窗口)对话区右边界 x≈971,而**卡片左边界 x≈858,压进对话区约 113px**。
9
+ 如实记录一点:这个位置关系随帧宽变化——1400px 下理论值是 `1400−16−400=984`,本身并不越界;
10
+ 问题是宽度写死 400px,窗口一窄就必然压住正文(这正是截图里的情形)。
11
+ 同时定案 A(悬浮胶囊,可随时收起/隐藏)+ C(停靠原生右侧栏)。
12
+
13
+ ### 位置:宽度由预算决定,不再固定 400px
14
+
15
+ - A1 新增纯函数 `floatPlacement({frameWidth, laneRight, railLeft, maxWidth})` →
16
+ `{width, avail, capped, bleeds}`:`avail = 帧宽 − 8 − 对话区右边界`,有右栏时再取
17
+ `min(avail, 帧宽 − 右栏左边界 − 10)`;`width = min(上限, max(240, avail))`。
18
+ 语义是**浮层只使用对话区右侧那条预留带,不越过对话区右边界**;只有带宽跌破 240px(胶囊再窄就不可用)
19
+ 才允许压入,并把这种情况如实报成 `bleeds`。
20
+ - A2 浮层 `right` 锚定帧右边缘,宽度写进 CSS 变量 `--dsa-float-w`(不再有 `width:min(400px,92vw)`);
21
+ 挂载、窗口 resize、右栏开合(`ResizeObserver`)时重算,流式刷新只重渲染不清算几何。
22
+ - A3 新增配置 `floatMaxWidth`(240–560,默认 **340**):实际宽度是 `min(上限, 可用带宽)`。
23
+ 旧的 400px 在多数窗口下本就超预算,故默认下调到 340。
24
+ - A4 浮层节点带 `data-layer-width` 与 `data-dsa-float-mode`:出问题时截图即可定位几何与形态。
25
+
26
+ ### 形态:默认胶囊,随时收起或隐藏
27
+
28
+ - A5 新增配置 `floatMode`(`capsule` | `full` | `off`,默认 **`capsule`**):作答期只有一行,
29
+ 完成后显示问题首句;`full` 展开为完整卡片栈;`off` 只留一个带计数的圆点启动器。
30
+ - A6 运行时可即时切换且**不写回配置**:卡片标题栏右侧新增两个**图标按钮**「⇥ 收起为胶囊 / × 隐藏浮层」
31
+ (完整文案放在 `title` 与 `aria-label`——中文全称 12 个字以上,写成文字会把标题栏挤成两行),
32
+ 它们只改会话内状态(`state.floatMode`),刷新后回到设置页的默认形态——按一下隐藏不会变成永久设置。
33
+ 这两个按钮只有浮层里的卡片才有;被原生右侧栏 tab 承载的同一张卡不传该回调,因此不出现(宿主 tab 不会遮挡对话区)。
34
+ - A7 设置页新增「浮层默认形态」「浮层宽度上限」两项控件。
35
+
36
+ ### 视觉
37
+
38
+ - A8 卡片阴影 `0 10px 30px rgba(0,0,0,.22)` → `0 4px 16px rgba(0,0,0,.10)`
39
+ (DSH 主题**没有**阴影令牌,只能硬编码 rgba;数值参照其 14 个 `--dsw-alias-*` 令牌的对比度下调)。
40
+
41
+ ### 测试
42
+
43
+ - 新增 `test/float-placement.mjs`:把几何写成断言——旧实现固定 400px,在**帧宽收窄**时左边界必然越到
44
+ 对话区右边界左侧(用例 fixture:帧 1380 / 边界 980 / 右栏左界 1010 → 旧左边界 964 < 980,新实现同组不越界);
45
+ 并如实保留一条「上报帧宽 1400 时旧实现 984 其实并未越界」,防止把一个随窗口变化的位置关系说成永远成立。
46
+ 另含 8 种帧宽 × 4 种右栏宽的不变量(有空间就绝不越界);带宽条带与极端值(390px 带宽得 390 且 `capped`
47
+ 为 false、`avail` 取夹取前的原始预算、NaN 退化到 240 而非 NaN、上限 9999 夹到 560、上限 10 夹到 240);
48
+ 纯函数性(不改输入、同入同出);默认形态必须是 `capsule`;以及源码里旧固定 400px 规则确实已消失
49
+ (防止只改测试不改实现)。
50
+ - `test/contract-test.mjs` 的两面默认值等价断言(`pure.CLIENT_DEFAULTS` 对 `host.DEFAULT_CONFIG`)覆盖新键。
51
+ - **既有契约用例改为显式要求 `floatMode: 'full'`**:`test/contract-test.mjs` 里读卡片正文的三处
52
+ (思考过程、刷新后重放、渲染证明回退)本来依赖「浮层就是卡片栈」,而新的默认形态是胶囊——
53
+ 这些用例断言的是**卡片内容**,所以它们显式要 `full`;胶囊这个默认值本身由 `float-placement` 断言。
54
+ 不改它们的话,默认值一改就会三处变红,而那不是回归。
55
+ - 版本三处同步到 1.5.0:`package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION`、`client.js` 的
56
+ `CLIENT_VERSION`(`test/verify.mjs` 会同时断言这三处)。
57
+
58
+ ### 本机实测结果(权威)
59
+
60
+ - `node --check` 对 `index.js`、`client.js` 与全部 `test/*.mjs`、`tools/compat-probe.mjs` 通过(exit 0)。
61
+ - `node test/verify.mjs` → **53 passed, 0 failed**(含 zh/en 两本词典的 `t()` 键覆盖断言)。
62
+ - `node test/contract-test.mjs` → **296 passed, 0 failed**。
63
+ - `node test/float-placement.mjs` → **38 passed, 0 failed**。
64
+ - `node test/smoke-test.mjs` → **138 passed, 0 failed**。
65
+ - 诚实记录:`float-placement` 第一轮报 **7 条红**,逐条查完**全部是我把测试写错**,实现未因此改动:
66
+ ① 旧实现越界的 fixture 手算错(1420/991 那组其实不越界);② 一条断言把左右关系写成了负值恒假;
67
+ ③④ 退化右栏用例期望 340/false,实测 240/true;⑤ 硬上限用例的 `avail` 只有 400,压根碰不到 560;
68
+ ⑥ 新增的 `capped` 断言把语义写反(`capped` 指"被可用带宽限制",命中硬上限时为 false);
69
+ ⑦ `avail` 断言误用了带右栏的用例(390 而非 421);⑧ 源码钩子去找根本不存在的
70
+ `setFloatMode('capsule')` 调用——控制项实际走 `floatControl('capsule','floatCollapse','⇥')` 的
71
+ `onFloatMode` 回调,正则已改。
72
+ - 本版一度新增 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」,**现已删除**:
73
+ 它们本身是错的——前者用「正则字面量 vs 除号」的启发式,把 8 个源文件全部误报为残损;
74
+ 后者定位词典块的缩进假设写错,解析出 0 个键并误报上百条缺失。会撒谎的检查器比没有检查器更糟。
75
+
76
+ > 仍未验证:**浏览器内的视觉观感**(浮层实际宽度是否等于预算、是否真的不越过对话区右边界)。
77
+ > 请按 README 第九节 **A 路径第 8 条**在真实页面确认(读 `data-dsa-float-mode` 与 `data-layer-width`),
78
+ > 单测替代不了这一步。1.5.0 前半段本工作区 shell 工具整体不可用
79
+ > (`SetNamedSecurityInfoW failed (Win32 5)`),当时的改动确实没跑过任何命令;故障解除后才补跑上述四项。
80
+
3
81
  ## 1.4.0
4
82
 
5
83
  **主题:一次系统性的代码走查——19 项改进,功能缺口、正确性边界、i18n、代码卫生一次补齐。**
package/README.md CHANGED
@@ -8,6 +8,11 @@
8
8
 
9
9
  聊天记录、任务详情、右侧栏内容……只要是能选中的文本都能用;**没装侧边卡片插件也能跑**(自动回退到内置浮层卡片)。
10
10
 
11
+ > **1.5.0 增强一览**(详见 CHANGELOG):内置浮层不再压住主对话——宽度改由纯函数 `floatPlacement`
12
+ > 按「对话区右侧可用带宽」计算(旧实现是固定 400px,在用户截图上压入正文约 113px);浮层默认是**一行高的胶囊**,
13
+ > 可随时「收起 / 隐藏」,运行时的形态切换不写回配置;新增 `floatMode`(默认 `capsule`)与
14
+ > `floatMaxWidth`(默认 340)两项设置;卡片阴影减半。
15
+
11
16
  > **1.4.0 增强一览**(详见 CHANGELOG):卡片**思考过程**(reasoning)可折叠展示并随答案流式渲染;
12
17
  > **已完成卡片写入 localStorage,刷新页面不丢**;答案渲染扩展链接/斜体/引用/表格;流式输出自动滚动到底;
13
18
  > 设置页补齐 `sideTimeoutMs` / `sideProvider` 控件;原生右侧栏 tab 可关闭(不再残留空壳);
@@ -60,10 +65,15 @@ dsh-sidecard-ask/
60
65
  ├── README.md 本文件
61
66
  ├── CHANGELOG.md 版本变更记录
62
67
  ├── LICENSE MIT
68
+ ├── design-preview/ 1.5.0 的方案讨论资料,**不参与发布**:00–02 是手绘 SVG 示意图(非截图),
69
+ │ 03-prototype-ac.html 是可直接双击打开的可点按原型(自带近似色板,非真实观感)
70
+ ├── tools/
71
+ │ └── compat-probe.mjs DSH 版本能力探测(§7 矩阵的来源)
63
72
  └── test/
64
73
  ├── harness.mjs 测试替身:假宿主 ctx / 假 req·res / 假子代理 provider / 合成浏览器 + 迷你 React
65
74
  ├── verify.mjs 静态完整性:清单、补丁、两半导出面、i18n 键覆盖、零依赖承诺
66
75
  ├── contract-test.mjs 两端契约:常量一致性、响应信封形状、SSE 逐帧往返、纯函数等价、槽位注册与渲染
76
+ ├── float-placement.mjs 浮层几何:宽度预算不越过对话区右边界、胶囊默认形态、源码钩子(1.5.0 新增)
67
77
  └── smoke-test.mjs 宿主端到端:流式、截断、取消、持久化与重启、失败分支、并发上限、传输围栏
68
78
  ```
69
79
 
@@ -139,6 +149,8 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
139
149
  | `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
140
150
  | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选 / 强制 DSH 原生右侧栏 / 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
141
151
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
152
+ | `floatMode` | `capsule` \| `full` \| `off` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈 / 只留一个计数圆点。运行时可即时收起、隐藏,但不写回本项——刷新后仍回到这里设的默认 | 立即 |
153
+ | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
142
154
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
143
155
  | `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
144
156
  | `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
@@ -150,6 +162,30 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
150
162
 
151
163
  非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
152
164
 
165
+ ### 4.1 内置浮层的位置规则(`floatPlacement`)
166
+
167
+ 用户实测报告过一个问题:浮层是 `position:fixed; right:16px; width:min(400px,92vw)` 的实心卡片。
168
+ 实测那一次(1400px 宽的窗口)对话区右边界在 x≈971,而浮层左边界在 x≈858,**压住正文约 113px**,
169
+ 最后几行答案被盖住。两点如实说明:**旧实现并不是任何窗口宽度下都越界**——1400px 窗口理论上
170
+ `1400-16-400=984`,还在 971 右侧;越界与否取决于当时的帧宽与对话区带宽,而固定 400px 的选择
171
+ 让「窗口一窄就必然压住正文」。这条读数(971 / 858 / 113px)是截图上的像素测量,不是日志里的一手数据。
172
+ 现在的位置由 `client.js` 里的纯函数 `floatPlacement` 决定:
173
+
174
+ ```js
175
+ // floatPlacement({ frameWidth, laneRight, railLeft = null, maxWidth = floatMaxWidth })
176
+ avail = 帧宽 - 8 - 对话区右边界 // 对话区右侧那条「预留带」
177
+ if (右栏存在) avail = min(avail, 帧宽 - 右栏左边界 - 10)
178
+ avail = max(240, floor(avail)) // 240px 以下胶囊也不可用,故为硬下限
179
+ width = min(floatMaxWidth, avail)
180
+ left = 帧宽 - 8 - width // 右锚定
181
+ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才可能为 true
182
+ ```
183
+
184
+ - 浮层**只使用对话区右侧的预留带**(右栏也在这条带里),窗口越窄它越窄,而不是越靠左。
185
+ - `capped: true` 表示「上限没用满,是被可用带宽压住的」——这是正常的自适应,不是错误。
186
+ - 挂载时、窗口 resize 时、右栏开合时(`ResizeObserver`)都会重算;流式刷新只重渲染,不动几何。
187
+ - 浮层根节点带 `data-layer-width`(实际宽度)与 `data-dsa-float-mode`:报告问题时可直接读这两个值。
188
+
153
189
  ---
154
190
 
155
191
  ## 五、侧边承载面与"侧边卡片插件"适配
@@ -163,7 +199,10 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
163
199
  | 3 | **内置浮层卡片**(默认兜底) | 只需要 `shell.overlay` 槽位 | ——(这是保底面,永远可用) |
164
200
 
165
201
  - 装了 `dsh-better-sidebar`:卡片以它的 tab 形式出现在它的面板里,关闭卡片会同时 `closeTab`,不残留空 tab。
166
- - 没装:自动使用内置浮层卡片(右下角卡片栈),功能完全一致——**这条路径是本插件的默认与保底路径**。
202
+ - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是本插件的默认与保底路径**。
203
+ 浮层默认是**贴帧右边缘的胶囊**(一行高,只有状态与问题首句),点击才展开成完整卡片,
204
+ 标题栏右侧的「收起 / 隐藏」可随时收回;它的宽度由 `floatPlacement` 计算,
205
+ 只会占用**对话区右侧那条预留带**,不会压住正文(细节见 §4.1)。
167
206
  - 强制指定了一个不可用的承载面:回退到内置浮层,并在卡片上标注「已回退」。
168
207
 
169
208
  ### 5.1 与 dsh-better-sidebar 0.22.1 的适配核对(2026-09-28)
@@ -407,6 +446,14 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
407
446
  5. 结束后状态变「已完成」;底部出现「复制 / 继续追问 / 关闭」;
408
447
  6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题,卡片内容**重置换行**后继续流式;
409
448
  7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。
449
+ 8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
450
+ 展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
451
+ 用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
452
+ - `data-dsa-float-mode` 应为 `capsule` / `full` / `off`;
453
+ - `data-layer-width` 应等于 `min(floatMaxWidth, 对话区右边界到帧右边缘的可用带宽)`。
454
+ **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
455
+ 把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
456
+ 只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
410
457
 
411
458
  **失败信号**:卡片停在「作答中…」不动 = SSE 帧没到达(看 console 是否有 `/sidecard-ask/api/ask` 报错);`done.streaming=false` = 该版本没有流式帧(属预期降级,卡片会写明)。
412
459
 
@@ -440,13 +487,20 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
440
487
 
441
488
  ```powershell
442
489
  cd D:\Users\34332\AI\dsh-sidecard-ask
443
- node test/verify.mjs # 静态:清单/补丁/导出面/i18n/零依赖
444
- node test/contract-test.mjs # 契约:常量、信封、SSE 逐帧、纯函数、槽位注册与渲染
445
- node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失败分支/并发/围栏
490
+ node test/verify.mjs # 静态:清单/补丁/导出面/i18n/零依赖
491
+ node test/contract-test.mjs # 契约:常量、信封、SSE 逐帧、纯函数、槽位注册与渲染
492
+ node test/float-placement.mjs # 几何:浮层宽度不越过对话区右边界(1.5.0 新增)
493
+ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失败分支/并发/围栏
446
494
  ```
447
495
 
448
- 三个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
449
- 当前规模:verify 53 项 + contract 296 项 + smoke 138 项 = **487 项全部通过**。
496
+ 四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
497
+ 当前规模(1.5.0 本机实测,四项均 exit 0):verify **53** 项 + contract **296** 项 + float-placement **38** 项 +
498
+ smoke **138** 项 = **525 项全部通过**。
499
+
500
+ > 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
501
+ > 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
502
+ > 后者定位词典块的缩进假设写错,解析出 0 个键并误报上百条缺失。**跑不了测试时宁可标注「未验证」,
503
+ > 也不要引入会撒谎的检查器**——真实把关始终只有上面那四个 `test/*.mjs`。
450
504
 
451
505
  ### 10.2 版本能力探测(§7 矩阵的来源)
452
506
 
@@ -553,9 +607,10 @@ Host: ctx.subagents.start('spawn')
553
607
 
554
608
  ```powershell
555
609
  npm whoami # 先确认登录态;本机用的是 granular Publish token
556
- node test/verify.mjs; node test/contract-test.mjs; node test/smoke-test.mjs
610
+ node test/verify.mjs; node test/contract-test.mjs; node test/float-placement.mjs; node test/smoke-test.mjs
557
611
  npm publish --access public # 1.0.1 那次被 npm 暂存(staged)后才转正;再遇到可用 --otp=<验证码>
558
612
  git push origin main # 仓库:https://github.com/HERO476/dsh-sidecard-ask
613
+ git tag v1.5.0; git push origin v1.5.0 # 版本号与 package.json 一致
559
614
  ```
560
615
 
561
616
  改名/迁移时的额外两步(1.1.0 实际做过):
@@ -582,6 +637,20 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
582
637
  本机实测能识别:`conversation.*`(聊天)、`rightbar` / `sidebar.right.*` / 名字含 task·todo·schedule·team·job·plan 的面板(任务)。
583
638
  2. **浏览器内的视觉与交互未在本机验证**:本工作区没有浏览器自动化工具,因此"按钮出现在选区旁""卡片逐字增长""浮层不挡操作"这些**只能由你按第九节点一遍**。
584
639
  已做的保障是:只使用 `--dsw-alias-*` 主题令牌、只在槽位内渲染(`shell.overlay` / `settings.section` / `conversation.input.right`)、不写 `document.body`、不 import harness 客户端包。
640
+ 2b. **1.5.0 的浮层改动:四个测试脚本已在本机实跑通过(各 exit 0),但页面里的观感仍未验证**:
641
+ 写这个版本的前半段,本工作区的 shell 工具整体不可用(任何命令都返回
642
+ `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<工作区>)`),所以当时的改动**没有跑过任何命令**,
643
+ 我也没有资格说它"验证过"。后半段该故障解除后已补跑:`node --check` 对全部源文件通过,
644
+ `verify` 53 / `contract` 296 / `float-placement` 38 / `smoke` 138 **全部 0 failed**。
645
+ 其中 float-placement 第一轮曾报 7 条红,逐条查完**全部是我把测试写错**(旧实现的越界 fixture 算错、
646
+ `avail` 期望值用了带右栏的用例、`capped` 语义写反、以及去找一个源码里并不存在的
647
+ `setFloatMode('capsule')` 调用点——真实实现走 `floatControl('capsule','floatCollapse','⇥')` 的
648
+ `onFloatMode` 回调),实现本身没有因此改动。
649
+ 仍未验证的是**浏览器内的视觉观感**:浮层实际宽度是否等于预算、是否真的不越过对话区右边界,
650
+ 必须按第九节 **A 路径第 8 条**在真实页面上确认(读 `data-dsa-float-mode` 与 `data-layer-width`),测试替代不了。
651
+ 此外,本版一度新增过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个"替代检查",
652
+ **现已删除**:它们本身有错(前者把 8 个源文件全误报为残损,后者解析出 0 个词典键并误报上百条缺失),
653
+ 属于会撒谎的检查器——跑不了测试时应当标注"未验证",而不是用一个错的检查器去证明没问题。
585
654
  3. **原生右侧栏承载面未在真实宿主上验证**:本机 profile 虽已启用 `@deepseek-ai/dsh-client-ui-sidebar-right`,但没有浏览器控件去确认 tab 是否真的渲染。
586
655
  为此加了一道**渲染证明**:适配器接受了打开请求后 600ms 内若没有观察到我们的卡片组件挂载,就自动把卡片移到内置浮层(流式内容不丢,因为卡片数据在 store 里)。
587
656
  4. **主对话承载的"发送"依赖客户端会话服务**:`ctx.sessions.using(...).prompt(...)` 在会话未被保留/不可用时降级为草稿写入,
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.4.0'
50
+ const CLIENT_VERSION = '1.5.0'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -96,6 +96,8 @@ window.__ModuleLoader__.load({
96
96
  sideTools: 'readonly',
97
97
  sideTimeoutMs: 180000,
98
98
  sideProvider: 'auto',
99
+ floatMode: 'capsule',
100
+ floatMaxWidth: 340,
99
101
  }
100
102
 
101
103
  /** Zone classification: slot-key patterns the harness itself renders. */
@@ -104,6 +106,54 @@ window.__ModuleLoader__.load({
104
106
  task: [/\btask/i, /\btodo/i, /\bschedule/i, /\bteam/i, /\bjob/i, /\bplan/i, /^rightbar\b/, /^sidebar\.right\b/],
105
107
  }
106
108
 
109
+ // ─────────────────────────────────────────────────────────────────────
110
+ // §1b floating-layer (A) geometry
111
+ // ─────────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Placement rules for the floating layer, mirrored by
115
+ * `test/float-placement.mjs`.
116
+ *
117
+ * The layer is anchored to the FRAME's right edge, so it can only ever
118
+ * grow LEFT into the conversation column. Everything here exists to stop
119
+ * that: the layer may use the strip to the right of the conversation
120
+ * column's right boundary (where the right rail lives), but it never
121
+ * crosses that boundary while there is at least `minWidth` of room.
122
+ */
123
+ const FLOAT_GAP_RIGHT = 8
124
+ const FLOAT_GAP_SIDEBAR = 10
125
+ const FLOAT_MIN_WIDTH = 240
126
+ const FLOAT_MAX_WIDTH_LIMIT = 560
127
+
128
+ /**
129
+ * The floating layer's width for one frame.
130
+ *
131
+ * Pure (no DOM) so both the runtime and the tests can call it:
132
+ * `avail` is the distance from the conversation column's right boundary to
133
+ * the frame's right edge, minus the right rail when one is present. The
134
+ * boundary is treated as un-crossable while `avail >= minWidth`; below that
135
+ * the layer gives up and clamps to `minWidth` (a pill narrower than that is
136
+ * unusable), which is the only case where it may overlap the column.
137
+ *
138
+ * @param {object} input - `frameWidth`, `laneRight`, `railLeft`, `maxWidth`.
139
+ * @returns {{width: number, avail: number, capped: boolean, bleeds: boolean}}
140
+ */
141
+ function floatPlacement({ frameWidth, laneRight, railLeft = null, maxWidth = CLIENT_DEFAULTS.floatMaxWidth }) {
142
+ const frame = Number.isFinite(frameWidth) ? frameWidth : 0
143
+ const lane = Number.isFinite(laneRight) ? laneRight : 0
144
+ const cap = Math.min(
145
+ FLOAT_MAX_WIDTH_LIMIT,
146
+ Math.max(FLOAT_MIN_WIDTH, Number.isFinite(maxWidth) ? maxWidth : CLIENT_DEFAULTS.floatMaxWidth),
147
+ )
148
+ let avail = frame - FLOAT_GAP_RIGHT - lane
149
+ if (Number.isFinite(railLeft)) avail = Math.min(avail, frame - railLeft - FLOAT_GAP_SIDEBAR)
150
+ avail = Math.max(FLOAT_MIN_WIDTH, Math.floor(avail))
151
+ const width = Math.min(cap, avail)
152
+ // Left edge of a right-anchored layer of this width.
153
+ const left = frame - FLOAT_GAP_RIGHT - width
154
+ return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
155
+ }
156
+
107
157
  // ─────────────────────────────────────────────────────────────────────
108
158
  // §2 i18n
109
159
  // ─────────────────────────────────────────────────────────────────────
@@ -185,6 +235,20 @@ window.__ModuleLoader__.load({
185
235
  setSurfaceFlow: '内置浮层卡片',
186
236
  setMaxChars: '最大字符数',
187
237
  setMaxCharsHint: '超过部分截断并在提问中标注',
238
+ setFloatMode: '浮层默认形态',
239
+ setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
240
+ setFloatModeFull: '直接展开成卡片',
241
+ setFloatModeOff: '关闭浮层(只留小圆点)',
242
+ setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
243
+ setFloatMaxWidth: '浮层宽度上限(px)',
244
+ setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
245
+ floatExpand: '展开侧边卡片',
246
+ floatCollapse: '收起为胶囊(不遮挡对话)',
247
+ floatHide: '隐藏浮层(只留小圆点)',
248
+ floatRestore: '恢复浮层',
249
+ floatCount: '{n} 条追问回答',
250
+ floatStreaming: '正在作答…',
251
+ floatDone: '已完成 {n} 条',
188
252
  setShortcut: '快捷键',
189
253
  setZones: '捕获区域',
190
254
  setZonesAuto: '全部区域',
@@ -307,6 +371,20 @@ window.__ModuleLoader__.load({
307
371
  setSurfaceFlow: 'Built-in floating card',
308
372
  setMaxChars: 'Max characters',
309
373
  setMaxCharsHint: 'Longer selections are truncated and marked in the question',
374
+ setFloatMode: 'Floating layer default',
375
+ setFloatModeCapsule: 'Capsule (never covers the conversation)',
376
+ setFloatModeFull: 'Expanded card stack',
377
+ setFloatModeOff: 'Hidden (small launcher only)',
378
+ setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
379
+ setFloatMaxWidth: 'Floating layer max width (px)',
380
+ setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
381
+ floatExpand: 'Expand the side cards',
382
+ floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
383
+ floatHide: 'Hide the layer (leaves a small launcher)',
384
+ floatRestore: 'Restore the floating layer',
385
+ floatCount: '{n} follow-up answers',
386
+ floatStreaming: 'Answering…',
387
+ floatDone: '{n} done',
310
388
  setShortcut: 'Shortcut',
311
389
  setZones: 'Capture regions',
312
390
  setZonesAuto: 'Every region',
@@ -567,6 +645,11 @@ window.__ModuleLoader__.load({
567
645
  hostReachable: null,
568
646
  surface: 'flow',
569
647
  surfaceNote: null,
648
+ /**
649
+ * In-session override of `config.floatMode`, set by the layer's own
650
+ * collapse/hide buttons. `null` = follow the configured default.
651
+ */
652
+ floatMode: null,
570
653
  sessionId: null,
571
654
  trigger: null,
572
655
  popover: null,
@@ -603,6 +686,18 @@ window.__ModuleLoader__.load({
603
686
  return store.state
604
687
  }
605
688
 
689
+ /**
690
+ * In-session override of the configured floating-layer shape. Deliberately
691
+ * NOT persisted: the settings page owns the durable default, and a reload
692
+ * should return to it (a pill hidden by accident must not stay hidden
693
+ * across sessions).
694
+ * @param {string} mode - `capsule` | `full` | `off`.
695
+ */
696
+ function setFloatMode(mode) {
697
+ if (mode !== 'capsule' && mode !== 'full' && mode !== 'off') return
698
+ store.set({ floatMode: mode })
699
+ }
700
+
606
701
  /** Replace one card by id (immutably; a no-op when it is gone). */
607
702
  function patchCard(id, patch) {
608
703
  const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
@@ -1160,7 +1255,7 @@ window.__ModuleLoader__.load({
1160
1255
  (\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
1161
1256
  plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
1162
1257
  Every surface we draw declares it. The property is inert in a browser. */
1163
- .dsa-layer,.dsa-trigger,.dsa-pop,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1258
+ .dsa-layer,.dsa-trigger,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
1164
1259
  .dsa-layer{pointer-events:none;position:relative;z-index:60}
1165
1260
  .dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
1166
1261
  padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
@@ -1193,6 +1288,9 @@ window.__ModuleLoader__.load({
1193
1288
  .dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
1194
1289
  color:#fff;font-weight:500}
1195
1290
  .dsa-btn:disabled{opacity:.55;cursor:default}
1291
+ /* Icon-only variant (the floating layer's collapse/hide controls): square and
1292
+ quiet so the card title bar stays one line and the title keeps the weight. */
1293
+ .dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
1196
1294
  .dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
1197
1295
  .dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
1198
1296
  font:inherit;font-size:12px;cursor:pointer}
@@ -1201,9 +1299,37 @@ window.__ModuleLoader__.load({
1201
1299
  .dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
1202
1300
  border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1203
1301
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1204
- box-shadow:0 10px 30px rgba(0,0,0,.22);font-size:13px;line-height:19px}
1205
- .dsa-stack{pointer-events:none;position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;
1206
- width:min(400px, 92vw);max-height:70vh;overflow:auto}
1302
+ box-shadow:0 4px 16px rgba(0,0,0,.10);font-size:13px;line-height:19px}
1303
+ /* The floating layer (A). Anchored to the frame's RIGHT edge and sized by
1304
+ \`floatPlacement\` through --dsa-float-w, so it grows left only as far as the
1305
+ conversation column allows. pointer-events stays off on the container so the
1306
+ empty area around the pill never eats a click meant for the conversation. */
1307
+ .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
1308
+ display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
1309
+ .dsa-float > *{pointer-events:auto}
1310
+ .dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
1311
+ .dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
1312
+ padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
1313
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1314
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1315
+ box-shadow:0 2px 10px rgba(0,0,0,.14)}
1316
+ .dsa-capsule:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1317
+ .dsa-capsule:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
1318
+ .dsa-capsule-dot{flex:none;width:8px;height:8px;border-radius:50%;
1319
+ background:var(--dsw-alias-state-idle-primary, #8a8f98)}
1320
+ .dsa-capsule-dot[data-status="streaming"]{background:var(--dsw-alias-brand-primary, #4c8dff)}
1321
+ .dsa-capsule-dot[data-status="done"]{background:var(--dsw-alias-state-success-primary, #2e9e5b)}
1322
+ .dsa-capsule-dot[data-status="error"]{background:var(--dsw-alias-state-error-primary, #c0392b)}
1323
+ .dsa-capsule-dot[data-status="stopped"]{background:var(--dsw-alias-state-warn-primary, #b8860b)}
1324
+ .dsa-capsule-label{font-weight:600;white-space:nowrap}
1325
+ .dsa-capsule-q{min-width:0;flex:1;color:var(--dsw-alias-label-secondary, #666);
1326
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1327
+ .dsa-capsule-chev{flex:none;color:var(--dsw-alias-label-secondary, #888)}
1328
+ .dsa-launcher{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;padding:6px 11px;
1329
+ border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
1330
+ border:1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.45));
1331
+ background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-secondary, #666)}
1332
+ .dsa-launcher:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
1207
1333
  .dsa-answer{max-height:44vh;overflow:auto;word-break:break-word}
1208
1334
  .dsa-answer .dsa-p{margin:0 0 6px}
1209
1335
  .dsa-answer .dsa-ul{margin:0 0 6px;padding-left:18px}
@@ -1642,18 +1768,40 @@ window.__ModuleLoader__.load({
1642
1768
  }, t('close')))
1643
1769
  }
1644
1770
 
1645
- /** One answer card; `embedded` is true inside a host's tab. */
1646
- function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
1771
+ /**
1772
+ * One answer card; `embedded` is true inside a host's tab.
1773
+ *
1774
+ * `onFloatMode` is passed ONLY by the floating layer: it renders the
1775
+ * collapse/hide controls that let the user get the layer out of the way
1776
+ * (the layer is the only surface that can cover the conversation). A host
1777
+ * tab that embeds the same card passes no callback and gets no buttons.
1778
+ */
1779
+ function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
1647
1780
  const [followOpen, setFollowOpen] = React.useState(false)
1648
1781
  const [draft, setDraft] = React.useState('')
1649
1782
  const answerRef = React.useRef(null)
1650
1783
  const config = store.state.config
1784
+ // Icon-only, so the header row keeps one line: the full labels
1785
+ // ("收起为胶囊(不遮挡对话)") are 12+ characters each and would wrap the
1786
+ // title bar. The words stay on `title`/`aria-label`.
1787
+ const floatControl = (mode, labelKey, glyph) => h('button', {
1788
+ type: 'button',
1789
+ className: 'dsa-btn dsa-btn-icon',
1790
+ title: t(labelKey),
1791
+ 'aria-label': t(labelKey),
1792
+ onClick: () => onFloatMode(mode),
1793
+ }, glyph)
1651
1794
  return h('section', { className: 'dsa-card', 'aria-label': t('answerTitle') },
1652
1795
  h('div', { className: 'dsa-row' },
1653
1796
  h('span', { className: 'dsa-title' }, t('answerTitle')),
1654
1797
  h('span', { className: 'dsa-badge' }, zoneLabel(card)),
1655
1798
  surfaceLabel !== null ? h('span', { className: 'dsa-badge' }, surfaceLabel) : null,
1656
- card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null),
1799
+ card.truncated ? h('span', { className: 'dsa-badge dsa-badge-warn' }, t('truncatedBadge', { n: card.droppedChars })) : null,
1800
+ typeof onFloatMode === 'function'
1801
+ ? h('span', { className: 'dsa-row', style: { marginLeft: 'auto' } },
1802
+ floatControl('capsule', 'floatCollapse', '⇥'),
1803
+ floatControl('off', 'floatHide', '×'))
1804
+ : null),
1657
1805
  h('pre', { className: 'dsa-quote' }, card.question),
1658
1806
  h(AnswerBody, { card, containerRef: answerRef }),
1659
1807
  card.toolFilter === 'unsupported'
@@ -1694,11 +1842,124 @@ window.__ModuleLoader__.load({
1694
1842
  surfaceLabel === null ? h('div', { className: 'dsa-muted' }, `${t('carrierSide')} · max ${config.maxChars}`) : null)
1695
1843
  }
1696
1844
 
1697
- /** The flow surface: a fixed stack of cards at the frame's bottom-right. */
1698
- function FlowStack({ cards, actions }) {
1845
+ /**
1846
+ * The flow surface, i.e. the floating layer (A).
1847
+ *
1848
+ * Three shapes, all sharing one right-anchored container:
1849
+ * - `capsule`: a single ~34px pill (status + truncated question). The
1850
+ * default, because a one-line pill cannot bury the conversation.
1851
+ * - `full`: the classic card stack, collapsed on demand.
1852
+ * - `off`: only a small launcher, so nothing covers the page until asked.
1853
+ *
1854
+ * The width comes from {@link floatPlacement}, so the layer stops growing
1855
+ * left as soon as it would cross the conversation column's right boundary.
1856
+ */
1857
+ function FloatingLayer({ cards, actions }) {
1858
+ const containerRef = React.useRef(null)
1859
+ const state = useStoreState()
1699
1860
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
1700
- if (visible.length === 0) return null
1701
- return h('div', { className: 'dsa-stack', 'data-dsa-root': '' },
1861
+ const total = visible.length
1862
+ const mode = state.floatMode ?? state.config.floatMode ?? CLIENT_DEFAULTS.floatMode
1863
+ const maxWidth = state.config.floatMaxWidth ?? CLIENT_DEFAULTS.floatMaxWidth
1864
+
1865
+ // Measure the conversation column + right rail, then pin the layer's
1866
+ // width to what is left. Recomputed on frame resize and on rail toggles
1867
+ // (ResizeObserver), not once at mount. Every probe is optional: the test
1868
+ // harness's synthetic DOM has no `querySelectorAll`, and an older WebView
1869
+ // may have no `ResizeObserver` — neither may break the layer.
1870
+ React.useEffect(() => {
1871
+ const node = containerRef.current
1872
+ if (node === null || node === undefined) return undefined
1873
+ const selector = '[data-slot^="conversation"], .rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]'
1874
+ const all = () => {
1875
+ try {
1876
+ return typeof document.querySelectorAll === 'function'
1877
+ ? [...document.querySelectorAll(selector)]
1878
+ : []
1879
+ } catch { return [] }
1880
+ }
1881
+ const one = (query) => {
1882
+ try { return typeof document.querySelector === 'function' ? document.querySelector(query) : null } catch { return null }
1883
+ }
1884
+ let frame = 0
1885
+ const apply = () => {
1886
+ if (typeof node.style?.setProperty !== 'function') return
1887
+ const lane = one('[data-slot^="conversation"]')
1888
+ const rail = one('.rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]')
1889
+ const rootWidth = document.documentElement?.clientWidth ?? window.innerWidth
1890
+ const laneRight = lane === null
1891
+ ? rootWidth
1892
+ : lane.getBoundingClientRect().right
1893
+ const railLeft = rail === null ? null : rail.getBoundingClientRect().left
1894
+ const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
1895
+ node.style.setProperty('--dsa-float-w', `${placement.width}px`)
1896
+ node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
1897
+ // Readable placement, so a user report can say WHERE the layer sat.
1898
+ node.dataset.layerWidth = String(placement.width)
1899
+ }
1900
+ const schedule = () => {
1901
+ if (typeof window.requestAnimationFrame !== 'function') { apply(); return }
1902
+ if (frame !== 0) return
1903
+ frame = window.requestAnimationFrame(() => {
1904
+ frame = 0
1905
+ apply()
1906
+ })
1907
+ }
1908
+ apply()
1909
+ let observer = null
1910
+ if (typeof window.ResizeObserver === 'function') {
1911
+ observer = new window.ResizeObserver(schedule)
1912
+ for (const target of all()) {
1913
+ try { observer.observe(target) } catch { /* not an element we can watch */ }
1914
+ }
1915
+ }
1916
+ if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
1917
+ return () => {
1918
+ if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', schedule)
1919
+ if (observer !== null) { try { observer.disconnect() } catch { /* already gone */ } }
1920
+ if (frame !== 0 && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame)
1921
+ }
1922
+ }, [maxWidth, total, mode])
1923
+
1924
+ if (total === 0) return null
1925
+
1926
+ const dot = h('span', {
1927
+ className: 'dsa-capsule-dot',
1928
+ 'data-status': visible.some(card => card.status === 'streaming') ? 'streaming' : visible[0].status,
1929
+ })
1930
+ const label = visible.some(card => card.status === 'streaming')
1931
+ ? t('floatStreaming')
1932
+ : t('floatDone', { n: total })
1933
+ const firstQuestion = visible[0].question
1934
+
1935
+ const container = children => h('div', {
1936
+ className: 'dsa-float',
1937
+ 'data-dsa-root': '',
1938
+ ref: containerRef,
1939
+ 'data-dsa-float-mode': mode,
1940
+ }, children)
1941
+
1942
+ if (mode === 'off') {
1943
+ return container(h('button', {
1944
+ type: 'button',
1945
+ className: 'dsa-launcher',
1946
+ title: t('floatRestore'),
1947
+ onClick: () => setFloatMode('full'),
1948
+ }, dot, t('floatCount', { n: total })))
1949
+ }
1950
+
1951
+ if (mode === 'capsule') {
1952
+ return container(h('button', {
1953
+ type: 'button',
1954
+ className: 'dsa-capsule',
1955
+ title: t('floatExpand'),
1956
+ onClick: () => setFloatMode('full'),
1957
+ }, dot, h('span', { className: 'dsa-capsule-label' }, label),
1958
+ h('span', { className: 'dsa-capsule-q' }, firstQuestion),
1959
+ h('span', { className: 'dsa-capsule-chev' }, '›')))
1960
+ }
1961
+
1962
+ return container(h('div', { className: 'dsa-stack' },
1702
1963
  visible.map(card => h(AnswerCard, {
1703
1964
  key: card.id,
1704
1965
  card,
@@ -1709,7 +1970,8 @@ window.__ModuleLoader__.load({
1709
1970
  onCancel: () => actions.cancel(card),
1710
1971
  onSendToMain: () => actions.sendToMain(card),
1711
1972
  onClose: () => actions.close(card),
1712
- })))
1973
+ onFloatMode: setFloatMode,
1974
+ }))))
1713
1975
  }
1714
1976
 
1715
1977
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
@@ -1731,7 +1993,7 @@ window.__ModuleLoader__.load({
1731
1993
  onSubmit: payload => actions.submit(popover.candidate, payload),
1732
1994
  })
1733
1995
  : null,
1734
- h(FlowStack, { cards: state.cards, actions }),
1996
+ h(FloatingLayer, { cards: state.cards, actions }),
1735
1997
  state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
1736
1998
  }
1737
1999
 
@@ -1826,6 +2088,12 @@ window.__ModuleLoader__.load({
1826
2088
  ['chat+task', t('setZonesBoth')],
1827
2089
  ])),
1828
2090
  field(t('setMaxChars'), number('maxChars', 200, 60000), t('setMaxCharsHint')),
2091
+ field(t('setFloatMode'), select('floatMode', [
2092
+ ['capsule', t('setFloatModeCapsule')],
2093
+ ['full', t('setFloatModeFull')],
2094
+ ['off', t('setFloatModeOff')],
2095
+ ]), t('setFloatModeHint')),
2096
+ field(t('setFloatMaxWidth'), number('floatMaxWidth', 240, 560), t('setFloatMaxWidthHint')),
1829
2097
  field(t('setMinChars'), number('minChars', 0, 200)),
1830
2098
  field(t('setConcurrency'), number('maxConcurrentAsks', 1, 12)),
1831
2099
  field(t('setShortcut'), h('input', {
@@ -2953,6 +3221,7 @@ window.__ModuleLoader__.load({
2953
3221
  isDiagnosticsUnsupported,
2954
3222
  renderRichText,
2955
3223
  pickSurface,
3224
+ floatPlacement,
2956
3225
  localizeError,
2957
3226
  errorCodes: ERROR_CODES,
2958
3227
  resolveLanguage,
package/index.js CHANGED
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
42
42
  export const inject = ['webServer']
43
43
 
44
44
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.4.0'
45
+ export const PLUGIN_VERSION = '1.5.0'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -73,6 +73,16 @@ export const DEFAULT_CONFIG = {
73
73
  sideTools: 'readonly',
74
74
  sideTimeoutMs: 180_000,
75
75
  sideProvider: 'auto',
76
+ /**
77
+ * Floating-layer (A) surface. `capsule` = one-line pill that never covers
78
+ * the conversation, `full` = the classic card stack, `off` = hidden behind a
79
+ * small launcher. The geometry for all three lives in `client.js`:
80
+ * the layer is anchored to the frame's right edge and never crosses the
81
+ * conversation column's right boundary (see `FLOAT_*` there).
82
+ */
83
+ floatMode: 'capsule',
84
+ /** Hard cap, in CSS px, for the floating layer's width. */
85
+ floatMaxWidth: 340,
76
86
  }
77
87
 
78
88
  /**
@@ -89,6 +99,7 @@ const ENUMS = {
89
99
  sideSurface: ['auto', 'native-rightbar', 'better-sidebar', 'flow'],
90
100
  captureZones: ['auto', 'chat', 'task', 'chat+task'],
91
101
  sideTools: ['readonly', 'inherit'],
102
+ floatMode: ['capsule', 'full', 'off'],
92
103
  }
93
104
 
94
105
  /** Numeric keys with their accepted range. */
@@ -97,6 +108,7 @@ const NUMBERS = {
97
108
  minChars: [0, 200],
98
109
  maxConcurrentAsks: [1, 12],
99
110
  sideTimeoutMs: [5_000, 3_600_000],
111
+ floatMaxWidth: [240, 560],
100
112
  }
101
113
 
102
114
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",