dsh-sidecard-ask 1.8.2 → 1.8.3

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,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.3
4
+
5
+ **主题:右侧栏打开时,答案卡直接铺满它、读成栏内的一页——而不是 340px 的浮层压在右栏正文上。**
6
+
7
+ 起因是用户截图:右栏开着「团队任务板」,答案卡浮在它上面,正文被挤成一条窄缝,标题与操作行占掉大半。
8
+
9
+ - **`dockRail`(默认 `true`)**:右栏可见且已展开时,把卡片铺到**标签条下方**的内容区——宽度取右栏宽,
10
+ 高度取到帧底;右栏自己的标签条仍然露出可点。这个形态下卡片去掉圆角、描边与阴影,只留一条左边线
11
+ 与右栏内容区分界;标题栏(追问 + 徽标 + ⇥/×)与底部操作行固定上下两端,`AnswerBody` 独立滚动,
12
+ 长答案不再把操作行推出视野。收起为胶囊(⇥)、关闭(×)或关掉本项,立即回到常规浮层。
13
+ - **几何是量出来的,不是猜的**:`dockedBodyRect(node)` 取右栏面板的 `getBoundingClientRect()`,若它在
14
+ `[data-dockkit-pane]` 里,再把顶边抬到所有 `[data-dockkit-tab]` 的底边——否则卡片会盖住右栏自己的标签条。
15
+ `findRail(scope)` 按世代尝试选择器(最具体的在前):
16
+ `[data-dsa-rail-host]`(本插件自己渲染在右栏里的 `CardHost` 节点——它所在的位置就是「一页正文」)→
17
+ `[data-sidebar-right-panel][data-sidebar-right-open] [data-dockkit-pane]` → 面板本身 →
18
+ `[data-sidebar-right-session][data-sidebar-right-open]` → `[data-dockkit-pane]` / `[data-dockkit-column]`
19
+ (只渲染 dockkit 标记的构建)→ `.rightbar` → `[data-slot^="sidebar.right"]`;
20
+ **只接受展开态**(折叠的面板会滑出帧外,它的 rect 不能用;`railOnScreen` 负责拒绝),量不到、
21
+ 宽 < 280px 或高 < 240px 一律回退浮层。
22
+ 这些标记不是文档给的,是从安装包里的 `@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js` 读出来的。
23
+ - **纯函数 `railDock`**:`enabled` 为假、坐标非有限、宽高低于 280/240 → `null`(= 不 dock);矩形夹进
24
+ `[0,frameWidth] × [0,frameHeight]`,夹完仍不足最小尺寸也返回 `null`;`clipped` 表示「画出来的不是量到的」——
25
+ 尺寸被夹小,或原点被拉回帧内(右栏入场动画期间可能量到负坐标)。已导出到 `api.pure`,测试与运行时共用一份实现。
26
+ - **渲染**:`docked = mode === 'full' && dock !== null`;容器改 `data-dsa-float-mode="dock"` 并写 inline
27
+ `left/top/width/height`(同时释放基类的 `right/bottom`,否则会过约束),`.dsa-stack` 带
28
+ `data-dsa-dock-body="<px>"` 供对照;三种形态复用同一份 `cardNodes`,没有分叉出第二套卡片。
29
+ - **设置页**新增「右侧栏打开时占满它」复选框(只写草稿,点保存才落盘),并给出「收起为胶囊或关闭即恢复原状;
30
+ 右侧栏不在时不生效」的说明;三个 i18n 键(zh/en 各三条)。
31
+ - **为什么不做成"真的注册右栏的一页"(如实)**:`sidebarRightTabs` / `sidebarRight` 两个服务在当前构建里
32
+ **确实存在**(从 `app.asar` 里读到 `ctx.reflect.provide("sidebarRightTabs", tabs)`),注册形状
33
+ (`register({id, kind, guide, …})`)与槽派发键(`entryKey = definition?.id ?? tab.kind`,本插件两处都是
34
+ `sidecard-ask:card`)也逐一核对过;但实测 `controller.openTab` 路径仍回落到浮层,且没定位到抛错点。
35
+ 在它修好之前,`dockRail` 是**视觉等价、失败即回退**的兜底层——README §6.1 仍把它标为占位接口,没有说成官方能力。
36
+ - **已知代价(如实)**:dock 期间会盖住右栏内容区里的其他面板(那些面板仍在下面),也会暂时压住右栏的
37
+ 拖拽改宽手柄;这两点都写进了 README §4.2 与代码注释。
38
+ - 测试:`float-placement` 新增 **40** 项(`railDock` 的夹取、最小尺寸、`clipped` 语义、不修改入参、非有限输入、
39
+ `dockRail` 默认值、源码钩子、上面三个缺陷的回归断言,以及承载面自动链的顺序断言);`contract` 新增 **8** 项
40
+ (设置页复选框:唯一一行、单个 checkbox、默认勾选、带写草稿的 onChange、说明文案;承载面 `auto` 的
41
+ 新偏好);3 个新 i18n 键由既有词典用例自动覆盖。
42
+ 总项数 1293 → **1350**。
43
+ - **发布前的一次真实回合(用户截图 + 一个「?」)修掉的三个缺陷**——首次交付的 dock 在真机上**没有生效**:
44
+ 卡片仍以约 505px 浮在右栏上,而右栏里留着一个正文写着「该卡片已关闭」的空 tab。
45
+ 1. `apply()` 里残留 `rail` 变量:改用 `railBox` 后旧守卫没删干净,一旦 `ResizeObserver` 回调进来就抛
46
+ `ReferenceError: rail is not defined`,并且**右栏节点永远不会被 observe**。已删,并加回归断言
47
+ (源码里不得再出现 `observed.has(rail)`)。
48
+ 2. **右栏开合不触发重算**:effect 依赖只有 `[maxWidth, total, mode, dockWanted]`,而被观察的是挂载时
49
+ 就已存在的节点——右栏是后开的,量不到就永远不 dock。现在的触发源有四个:`window resize`、
50
+ `ResizeObserver`(对话区 + 当前被接受的那个右栏节点)、**只盯属性的 `MutationObserver`**
51
+ (`data-sidebar-right-open` / `data-dockkit-pane-active` / `style` / `class`;**不盯 childList**,
52
+ 否则流式 token 每帧都会唤醒几何计算)、以及挂载后 `[80,200,420,800]ms` 四次重测(罩住右栏滑入动画)。
53
+ 3. **不再靠猜右栏标记**:`CardHost` 自己渲染在右栏里的节点就是「一页正文」,它的矩形就是答案。三个
54
+ `CardHost` 包装节点加 `data-dsa-rail-host`,并作为 `RAIL_SELECTORS` 的**第一优先**锚点(设置页根节点
55
+ 故意不加,否则浮层会停靠到设置面板上);其后才是 `data-sidebar-right-*`、dockkit 世代、`.rightbar`
56
+ 与 `sidebar.right` 槽。折叠态另由 `railOnScreen()` 拒绝(`left >= frameWidth - 1` 或
57
+ `top >= frameHeight - 1` 视为不在屏上),不会被 `railDock` 夹进视野。
58
+ - **诊断属性**:浮层根节点新增 `data-dsa-dock`,取值 `dock` / `no-rail` / `rail-too-small` / `off` / `mode`,
59
+ 一眼区分「没找到右栏」「找到了但太窄」「开关关了」「当前不是完整卡片形态」。
60
+ - **承载面 `auto` 的顺序改了:dsh-better-sidebar → 内置浮层卡片;原生右侧栏退出自动链。**
61
+ 原因是那个空 tab:卡片被塞进右栏只是一个 tab,tab 的生命周期不归插件管——卡片关掉后右栏仍留着一条
62
+ 读作「该卡片已关闭」的标签,还和用户自己的右栏标签抢位置,落点又取决于我们不控制的宿主细节。
63
+ 原生右侧栏保留为**强制选项**(下拉里标注「强制,不参与自动」,新增 `surfaceNativeForce` 文案),
64
+ 显式 `sideSurface: 'native-rightbar'` 仍然走它。下拉顺序同步为 auto / 插件 / 内置卡片 / 强制右侧栏。
65
+ 连带把三条依赖「auto 会挑原生」的既有用例改成显式指定 `native-rightbar`(它们测的是原生适配器本身,
66
+ 不该再由 auto 顶上来)。
67
+ - 版本号三处同步到 1.8.3。
68
+
69
+ 本机实测(四项均 exit 0):verify **57** / contract **989** / float-placement **78** / smoke **226**
70
+ = **1350 项全部通过**(1.8.2 为 1293;float-placement 由 38 → 78,含 `railDock` 用例、上面三个缺陷的回归钩子,
71
+ 以及「自动链里不出现原生右侧栏」的顺序断言;contract 972 → 989)。
72
+
73
+ > 未验证(如实记录):`design-preview/09-dock-{light,dark,narrow-light}.png` 三张图是**离屏渲染**——
74
+ > 无头 Edge 加载"从 `client.js` 切出的真实样式表 + 本机 uiskin 皮肤令牌真值 + 按 `app.asar` 里读到的
75
+ > 右栏标记搭的静态结构"的页面,**不是**运行中 DSH 窗口的截图。因此它证明的是"这套 CSS 在给定矩形里
76
+ > 摆成什么样",不证明真实页面里右栏的实测矩形、滚动性能,也不证明 dock 与右栏拖宽手柄的冲突手感。
77
+ > 这些仍需刷新页面人工确认(README §9 A 路径第 9 条给了可执行的判据)。
78
+
3
79
  ## 1.8.2
4
80
 
5
81
  **主题:提问框底部的「作答位置」从"两枚看不出选中哪一侧的标签"改成一眼可辨的分段芯片;并把 1.8.1 之后写在本地、尚未提交的弹层对比度修补一并发布。**
@@ -17,10 +93,16 @@
17
93
  `--dsw-alias-brand-primary` 提亮到 `#5b9cf5`,白色 600 字在其上低于 4.5:1 正文下限、也低于 3:1 大字下限。
18
94
  改法:`[data-ds-dark-theme]` 下选中侧改用深墨 `#0a1428`(实测 6.57:1);浅色保持白字
19
95
  (`#2563c0` 上 5.79:1)。令牌值取自本机 `uiskin-theme`,对比度按 WCAG 相对亮度公式实算,不是目测。
20
- - **弹层边框/阴影对比度修补(1.8.1 之后写在本地、未提交,本次一并发布)**:`.dsa-pop` 的 1px 边
21
- 改显式中灰 `rgba(127,127,127,.55)`(`border-l2` 落在弹层表面只量到 ≈1.3:1),阴影改两层衰减
96
+ - **弹层边框/阴影修补(1.8.1 之后写在本地、未提交,本次一并发布;含一处数字更正)**:`.dsa-pop` 的 1px 边
97
+ 改显式中灰 `rgba(127,127,127,.55)`,阴影改两层衰减
22
98
  `0 14px 36px rgba(0,0,0,.30), 0 3px 10px rgba(0,0,0,.14)`;`.dsa-pop` 内的次级文案改用
23
99
  `label-primary` 并保留 `.78` 不透明度。
100
+ **更正**:随该补丁一起写下的代码注释声称"外框用 `border-l2` 只量到 ≈1.3–1.7:1",实测不成立——
101
+ 按本机皮肤令牌复合(bg-base .55 over white → bg-overlay .98)后,`border-l2` 是 **1.95:1(浅)/ 2.88:1(深)**;
102
+ 1.40:1 那个数字属于内层 `border-l1` 的 1px 边。换成中灰 .55 后测得 **1.96:1(浅)/ 2.12:1(深)**——
103
+ 所以这一步是**色度修正(蓝对蓝 → 中性灰),不是亮度对比度提升**,深色下还略弱。
104
+ 真正抬高可读性的是两层阴影与次级文案换成 `label-primary`(6.11:1)。
105
+ 若需要更强的实体边框,中灰 .85 测得 ≈3.03:1,但视觉更硬,未擅自采用。
24
106
  - 测试:contract 新增 **16** 项钉住这个控件(唯一 `.dsa-seg`、恰好两个按钮、恰好一个
25
107
  `aria-pressed="true"`、每个选项带一枚内联 `svg`、`data-on` 只有一个真值、两个去向的标签不互换、
26
108
  品牌实底规则与深色覆盖规则必须出现在样式表里)。**点击切换本身仍然不可测**:harness 的
package/README.md CHANGED
@@ -156,10 +156,11 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
156
156
  |---|---|---|---|---|
157
157
  | `trigger` | `selection` \| `shortcut` \| `both` | `selection` | **触发方式**:选中即浮出按钮 / 只用快捷键 / 两者都要 | 改后立即(客户端读 /state) |
158
158
  | `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
159
- | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选 / 强制 DSH 原生右侧栏 / 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
159
+ | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
160
160
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
161
161
  | `floatMode` | `capsule` \| `full` \| `off` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈 / 只留一个计数圆点。运行时可即时收起、隐藏,但不写回本项——刷新后仍回到这里设的默认 | 立即 |
162
162
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
163
+ | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、关闭浮层、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
163
164
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
164
165
  | `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
165
166
  | `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
@@ -202,6 +203,35 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
202
203
  - 挂载时、窗口 resize 时、右栏开合时(`ResizeObserver`)都会重算;流式刷新只重渲染,不动几何。
203
204
  - 浮层根节点带 `data-layer-width`(实际宽度)与 `data-dsa-float-mode`:报告问题时可直接读这两个值。
204
205
 
206
+ ### 4.2 右侧栏打开时「占满它」(`dockRail`,1.8.3)
207
+
208
+ 340px 的浮层在右栏打开时会同时压住两块内容:主对话的最后几行、以及右栏自己的正文。1.8.3 起默认
209
+ (`dockRail: true`)把整张卡铺到右栏里,读起来像栏内的一页:
210
+
211
+ - 位置 = 右栏**标签条之下**的内容区矩形,宽度取右栏宽度,高度取到底部;右栏自己的标签条仍然露出、可点。
212
+ - 卡片在这个形态下没有圆角/描边/阴影(`.dsa-card` 的装饰被 `[data-dsa-float-mode="dock"]` 覆盖),只有一条左边线与右栏内容区分界。
213
+ - 标题栏(追问 + 徽标 + ⇥ / ×)与底部操作行固定上下两端,中间 `AnswerBody` 独立滚动——长答案不再把操作行推出视野。
214
+ - 收起为胶囊(⇥)、关闭(×)、或把 `dockRail` 关掉,都立即回到常规浮层;右栏宽度 < 280px 或高度 < 240px 时也回退(窄栏里铺满会把正文挤成每行 8–9 字)。
215
+ - 折叠的右栏不会被误用:`railOnScreen()` 会拒绝停在帧外(`left >= frameWidth - 1`)或尺寸不足的面板,量不到就回退,绝不把卡片画到帧外。
216
+ - 矩形从哪来:**优先量本插件自己渲染在右栏里的 `CardHost` 节点**(`[data-dsa-rail-host]`)——它所在的位置按定义就是「一页正文」,不依赖任何未被文档承诺的标记;找不到时再按 `data-sidebar-right-*`、dockkit 世代、`.rightbar`、`sidebar.right` 槽逐代回退。
217
+ - 什么时候重算:窗口改宽、右栏开合(只盯属性的 `MutationObserver`)、右栏自身改宽(`ResizeObserver`),以及挂载后的四次延时重测(罩住右栏的滑入动画)。流式刷新不会触发几何重算。
218
+
219
+ 为什么是「测量」而不是「右栏内的一页」:DSH 的右栏服务确实存在(`sidebarRightTabs` / `sidebarRight`,
220
+ 见 §6.1),本插件也有完整注册代码,但在当前构建里 `openTab` 路径实测仍回落到浮层,且没定位到抛错点。
221
+ 在它修好之前,`dockRail` 是视觉上等价、失败即回退的兜底层——它盖住右栏内容区的其他面板(那些面板仍在
222
+ 下面,取消勾选或收起胶囊即恢复),也会暂时压住右栏的拖拽改宽手柄。
223
+
224
+ 诊断:`data-dsa-float-mode="dock"` 表示处于该形态;`.dsa-stack` 上的 `data-dsa-dock-body="<px>"` 是量到的
225
+ 宽度(与卡片宽度一致)。浮层根节点还有 `data-dsa-dock`,它的取值直接说明**为什么**没有铺满:
226
+
227
+ | 值 | 含义 |
228
+ |---|---|
229
+ | `dock` | 正在铺满 |
230
+ | `no-rail` | 没找到已展开的右栏(右栏关着,或这个构建的标记与预期不同) |
231
+ | `rail-too-small` | 找到了,但宽 < 280px / 高 < 240px,或它停在帧外(折叠态被拒绝) |
232
+ | `off` | 设置里的 `dockRail` 被关掉了 |
233
+ | `mode` | 当前不是完整卡片形态(胶囊 / 隐藏),本来就不该铺满 |
234
+
205
235
  ---
206
236
 
207
237
  ## 五、侧边承载面与"侧边卡片插件"适配
@@ -210,15 +240,19 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
210
240
 
211
241
  | 顺序 | 承载面 | 依赖 | 失败时 |
212
242
  |---|---|---|---|
213
- | 1 | DSH 原生右侧栏 | `ctx.sidebarRightTabs`(注册 tab 类型)+ `ctx.sidebarRight`(`openTab`),并占用槽位 `sidebar.right.pane.tab` / `…tab.title` | 记 `surfaces.native.error`,落到下一层;**类型注册会回滚**(见下) |
214
- | 2 | **dsh-better-sidebar**(侧边卡片插件) | 客户端服务 `ctx.betterSidebar`:`registerTab` + `openTab(seed, scope)`;卡片 id 走 `tab.meta.cardId`,因此**要求其 `features` 含 `tabMeta`** | 同上(缺 `tabMeta` 时记 `no-tab-meta` 并跳过,而不是开一个空 tab) |
215
- | 3 | **内置浮层卡片**(默认兜底) | 只需要 `shell.overlay` 槽位 | ——(这是保底面,永远可用) |
243
+ | 1 | **dsh-better-sidebar**(侧边卡片插件) | 客户端服务 `ctx.betterSidebar`:`registerTab` + `openTab(seed, scope)`;卡片 id 走 `tab.meta.cardId`,因此**要求其 `features` 含 `tabMeta`** | 记 `surfaces.better.error`(缺 `tabMeta` 时记 `no-tab-meta` 并跳过,而不是开一个空 tab),落到下一层 |
244
+ | 2 | **内置浮层卡片**(兜底) | 只需要 `shell.overlay` 槽位 | ——(这是保底面,永远可用) |
245
+ | — | DSH 原生右侧栏 | `ctx.sidebarRightTabs`(注册 tab 类型)+ `ctx.sidebarRight`(`openTab`),并占用槽位 `sidebar.right.pane.tab` / `…tab.title` | **不在 `auto` 链里**:只有显式 `sideSurface: 'native-rightbar'` 才用它;注册失败会回滚(见下) |
216
246
 
217
247
  - 装了 `dsh-better-sidebar`:卡片以它的 tab 形式出现在它的面板里,关闭卡片会同时 `closeTab`,不残留空 tab。
218
- - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是本插件的默认与保底路径**。
248
+ 这是 `auto` 的**第一选择**。
249
+ - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是第二选择,也是保底路径**。
219
250
  浮层默认是**贴帧右边缘的胶囊**(一行高,只有状态与问题首句),点击才展开成完整卡片,
220
251
  标题栏右侧的「收起 / 隐藏」可随时收回;它的宽度由 `floatPlacement` 计算,
221
252
  只会占用**对话区右侧那条预留带**,不会压住正文(细节见 §4.1)。
253
+ - **原生右侧栏为什么退出 `auto`**:卡片被塞进右栏只是一个 tab,而这个 tab 的生命周期不归插件管——
254
+ 卡片关闭后右栏仍留着一条读作「该卡片已关闭」的空标签,和你自己的右栏标签抢位置,
255
+ 落点还取决于我们并不控制的宿主实现细节。它会保留成一个**强制选项**(下拉里标注「强制,不参与自动」)。
222
256
  - 强制指定了一个不可用的承载面:回退到内置浮层,并在卡片上标注「已回退」。
223
257
 
224
258
  ### 5.1 与 dsh-better-sidebar 0.22.1 的适配核对(2026-09-28)
@@ -280,7 +314,7 @@ better-sidebar 会把收到的每个 tab 描述符**镜像注册进同一个原
280
314
  | `sidecard-ask:card` | 本插件的**原生右侧栏**承载面(1.2.1 之前完全缺失) |
281
315
  | `dsh-better-sidebar:sidecard-ask:card:workbench` | 经 dsh-better-sidebar 镜像注册的承载面(独立 kind) |
282
316
 
283
- 两者共存、均 `active: true` —— 即"同 kind 冲突"已被消除,`auto` 默认走原生右侧栏,强制 `sideSurface: better-sidebar` 仍可用。
317
+ 两者共存、均 `active: true` —— 即"同 kind 冲突"已被消除。`auto` 走的是 **dsh-better-sidebar → 内置浮层卡片**这条链(1.8.3 起原生右侧栏只在显式 `sideSurface: native-rightbar` 时使用),两个强制选项都仍可用。
284
318
 
285
319
  ### 5.3 与 DSH 桌面版(Electron)的适配核对(2026-09-30)
286
320
 
@@ -426,7 +460,7 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
426
460
 
427
461
  | 缺口 | 适配实现 | 在真机/测试里的表现 |
428
462
  |---|---|---|
429
- | 0.1.2 / 0.1.3 没有原生右侧栏 | 承载面探测链:原生右侧栏 → `dsh-better-sidebar` → **内置浮层卡片**;不可用的一律不计入 `pickSurface` | `contract-test` 断言 `pickSurface('auto')` 在无插件组合下落到 `flow`;`auto` 在可用时优先原生 |
463
+ | 0.1.2 / 0.1.3 没有原生右侧栏 | 承载面探测链:原生右侧栏 → `dsh-better-sidebar` → **内置浮层卡片**;不可用的一律不计入 `pickSurface` | `contract-test` 断言 `pickSurface('auto')` 在无插件组合下落到 `flow`、两端可用时选 `better-sidebar`;`float-placement` 钉住自动链里不出现原生右侧栏 |
430
464
  | ≤0.1.6-alpha.1 没有 `using`/`retain` | 主对话提交**四级阶梯**(调用时逐级探测,不查版本表):`sessions.using` → `sessions.retain`+`release` → 槽位标准 prop `inputActions`(`setDraft` + `submit`)→ 仅写入输入框并明确提示"请按 Enter" | `contract-test` 逐级断言 `via`:`sessions.using` / `sessions.retain`(并断言 `release()` 被调用)/ `inputActions.submit` / `composer.draft` / 全无时抛 `no-main-carrier` |
431
465
  | ≤0.1.6-alpha.2 没有归档门 | 不再因"父会话已归档"直接拒绝:仍优先挑未归档代理,但只有归档候选时**照常尝试**,并在 `start` 事件里带 `parentArchived:true`;真被门拒绝时把空 turn 的 `refusal` 翻译成 `blocked-step` 并说明原因 | `smoke-test`:全归档组合仍能拿到答案;`refusal` 用例断言 `code=blocked-step` 且文案提到归档 |
432
466
  | 0.1.2-rc.1 没有进程内帧 | 增加**第二条流式源**:订阅 `session/event` 的持久化 `assistant/chunk`(`{turn,step,chunk}`,形状取自该版本自己的 `chunk-rows.js`),与帧源**互斥**(先说话的那个生效,绝不重复计一次文本) | `smoke-test`:`frameMode:'chunks'` 用例断言 delta 拼出完整答案、`streaming:true`、`streamSource:'chunks'`;同时断言两源并存时文本不重复 |
@@ -564,6 +598,14 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
564
598
  **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
565
599
  把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
566
600
  只有窗口窄到可用带宽不足 240px 时才允许压入(此时是设计内的降级,不是 bug)。
601
+ 9. **占满右侧栏(1.8.3,`dockRail`)**:先把右侧栏打开(任意面板,例如「团队任务板」),再追问一次 →
602
+ 答案卡应铺满**标签条下方**的整块内容区:右边缘贴帧边、下边缘贴窗口底,右栏的标签条仍然露出可点。
603
+ 点卡片标题栏的 ⇥(收起为胶囊)或 × → 卡片立即退出占满、回到常规浮层,右栏内容区恢复。
604
+ 自检:F12 读浮层根节点,`data-dsa-float-mode` 应为 `dock`,`data-dsa-dock` 也应为 `dock`;
605
+ `.dsa-stack` 上的 `data-dsa-dock-body` 应等于卡片宽度(px)。**没铺满就看 `data-dsa-dock`**:
606
+ `no-rail` = 没找到右栏,`rail-too-small` = 找到了但太窄或在帧外,`off` = 开关关了,`mode` = 不是完整形态。
607
+ 把右栏拖到 260px 宽 → 应自动回退成常规浮层(低于 280px 不铺满);再拖回 400px → 应重新铺满。
608
+ 设置页取消「右侧栏打开时占满它」并保存 → 同一操作不再铺满。
567
609
 
568
610
  **失败信号**:卡片停在「作答中…」不动 = SSE 帧没到达(看 console 是否有 `/sidecard-ask/api/ask` 报错);`done.streaming=false` = 该版本没有流式帧(属预期降级,卡片会写明)。
569
611
 
@@ -604,10 +646,13 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
604
646
  ```
605
647
 
606
648
  四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
607
- 当前规模(1.8.2 本机实测,四项均 exit 0):verify **57** 项 + contract **972** 项 + float-placement **38** 项 +
608
- smoke **226** 项 = **1293 项全部通过**(1.8.1 为 1277 项,其中 contract 由 956 → 972:新增 16 项钉住提问框
609
- 「作答位置」分段芯片——唯一 `.dsa-seg`、两个按钮、恰好一个 `aria-pressed`、各带一枚内联图标、
610
- 品牌实底规则与深色覆盖规则;点击切换本身不可测,原因见 1.8.2 的 CHANGELOG)。
649
+ 当前规模(1.8.3 本机实测,四项均 exit 0):verify **57** 项 + contract **989** 项 + float-placement **78** 项 +
650
+ smoke **226** 项 = **1350 项全部通过**(1.8.2 为 1293 项:contract 972 → 989,新增 8 项钉住设置页的「占满
651
+ 右侧栏」复选框、3 个新 i18n 键,以及承载面 `auto` 的新顺序;float-placement 38 → 78,新增 40 项覆盖 `railDock` 的夹取/最小尺寸/
652
+ `clipped` 语义、真机上 dock 没生效那次发现的三个缺陷(残留的 `rail` 变量、右栏开合不触发重算、
653
+ 右栏锚点必须优先用插件自己的 `[data-dsa-rail-host]` 节点),以及「自动链里不出现原生右侧栏」这条顺序断言)。更早的 1.8.1 为 1277 项,其中 contract
654
+ 由 956 → 972 是提问框「作答位置」分段芯片的 16 项——唯一 `.dsa-seg`、两个按钮、恰好一个
655
+ `aria-pressed`、各带一枚内联图标、品牌实底规则与深色覆盖规则;点击切换本身不可测,原因见 1.8.2 的 CHANGELOG。
611
656
 
612
657
  **真机探针(不属于四套自测)**:`.compat-report/scripts/composer-probe.mjs` 需要
613
658
  `playwright-core` 与本机 Edge,对**真实 DSH 页面**回答 composer DOM / 哪条写入路径真能改变
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.8.2'
50
+ const CLIENT_VERSION = '1.8.3'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -98,6 +98,10 @@ window.__ModuleLoader__.load({
98
98
  sideProvider: 'auto',
99
99
  floatMode: 'capsule',
100
100
  floatMaxWidth: 340,
101
+ // Docked presentation (1.8.3): when the right rail is on screen, the
102
+ // answer paints inside the rail's rectangle instead of floating as a
103
+ // 340px card over it. See §1b `railDock`.
104
+ dockRail: true,
101
105
  // Mirrors the Host's DEFAULT_CONFIG.quickActions (contract-asserted):
102
106
  // `builtin:*` entries are resolved through QUICK_ACTION_BUILTINS, while
103
107
  // custom entries (no builtin prefix) carry their own label/question.
@@ -250,6 +254,163 @@ window.__ModuleLoader__.load({
250
254
  return { width, avail, capped: width < cap, bleeds: lane - left > 1 }
251
255
  }
252
256
 
257
+ const DOCK_MIN_WIDTH = 280
258
+ const DOCK_MIN_HEIGHT = 240
259
+
260
+ /**
261
+ * How the Client half recognizes the right rail in the live DOM, most
262
+ * specific generation first.
263
+ *
264
+ * The rail has no published DOM contract, so the markers were read out of
265
+ * the installed build (`@deepseek-ai/dsh-client-ui-sidebar-right`): its panel
266
+ * is a div carrying `data-sidebar-right-panel="push|fullscreen"` and, while
267
+ * expanded, `data-sidebar-right-open`; each session surface also carries
268
+ * `data-sidebar-right-session`. Two older spellings are kept behind those so
269
+ * a downgraded shell does not silently lose the dock: `.rightbar` (pre-rail
270
+ * builds) and the `sidebar.right` slot subtree. The OPEN marker matters: the
271
+ * panel slides off the frame while collapsed, so its rect is off-screen and
272
+ * must not be docked into.
273
+ */
274
+ const RAIL_SELECTORS = [
275
+ // The plugin's own nodes are the most reliable anchor there is: a tab body
276
+ // rendered by `CardHost` IS a page body, so wherever it sits is exactly
277
+ // where a page goes. It also works on builds whose rail markers we have
278
+ // never seen — including the leftover "card closed" tab, which still
279
+ // occupies a page body.
280
+ '[data-dsa-rail-host]',
281
+ '[data-sidebar-right-panel][data-sidebar-right-open] [data-dockkit-pane]',
282
+ '[data-sidebar-right-panel][data-sidebar-right-open]',
283
+ '[data-sidebar-right-session][data-sidebar-right-open]',
284
+ // Dockkit-only builds: the pane is the page body, and a build that renders
285
+ // no `data-sidebar-right-*` marker at all would otherwise never dock. These
286
+ // are last because they say "a pane exists", not "a pane is open" — the
287
+ // on-screen check in `findRail` is what keeps a collapsed (off-frame) rail
288
+ // from being docked into.
289
+ '[data-dockkit-pane]',
290
+ '[data-dockkit-column]',
291
+ '.rightbar',
292
+ '[data-slot^="sidebar.right"]',
293
+ ]
294
+
295
+ /**
296
+ * The box a tab's BODY occupies inside one rail node.
297
+ *
298
+ * The pane's tab strip sits at the top of the same box, so a raw pane rect
299
+ * would make the card cover the rail's own tabs. The strip's bottom edge is
300
+ * measured instead (every tab carries `data-dockkit-tab`), which keeps the
301
+ * rail navigable and places the card exactly where a page's content goes.
302
+ * Without any tab to measure, the node's own top edge is the answer.
303
+ */
304
+ function dockedBodyRect(node) {
305
+ const rect = node.getBoundingClientRect()
306
+ let top = rect.top
307
+ const pane = typeof node.closest === 'function' ? node.closest('[data-dockkit-pane]') : null
308
+ const tabs = pane !== null && typeof pane.querySelectorAll === 'function'
309
+ ? pane.querySelectorAll('[data-dockkit-tab]')
310
+ : null
311
+ if (tabs !== null) {
312
+ for (const tab of tabs) {
313
+ if (typeof tab.getBoundingClientRect !== 'function') continue
314
+ const tabRect = tab.getBoundingClientRect()
315
+ if (Number.isFinite(tabRect.bottom) && tabRect.bottom > top) top = tabRect.bottom
316
+ }
317
+ }
318
+ return { left: rect.left, top, width: rect.width, height: rect.bottom - top }
319
+ }
320
+
321
+ /**
322
+ * Whether a measured rail box is actually on screen.
323
+ *
324
+ * The collapsed rail is animated off the frame (its rect sits past the right
325
+ * edge, or is zero-sized), so "an element matched" is not the same as "the
326
+ * user can see it". A box that starts outside the frame or is thinner than
327
+ * the minimum is rejected here rather than clamped into view by `railDock`.
328
+ */
329
+ function railOnScreen(box) {
330
+ if (box === null || box === undefined) return false
331
+ const frameW = Number.isFinite(document.documentElement?.clientWidth)
332
+ ? document.documentElement.clientWidth
333
+ : (typeof window.innerWidth === 'number' ? window.innerWidth : 0)
334
+ const frameH = Number.isFinite(document.documentElement?.clientHeight)
335
+ ? document.documentElement.clientHeight
336
+ : (typeof window.innerHeight === 'number' ? window.innerHeight : 0)
337
+ if (!Number.isFinite(box.left) || !Number.isFinite(box.top)) return false
338
+ if (box.width < DOCK_MIN_WIDTH || box.height < DOCK_MIN_HEIGHT) return false
339
+ // `left >= frameW` is the collapsed-rail case (parked past the edge); a
340
+ // negative left with a visible body is a rail mid-animation and is fine.
341
+ if (box.left >= frameW - 1) return false
342
+ if (box.top >= frameH - 1) return false
343
+ return true
344
+ }
345
+
346
+ /**
347
+ * The on-screen rail's dockable box, plus the node it was measured from.
348
+ *
349
+ * Every candidate is measured before it is accepted: a hidden or zero-sized
350
+ * node (a collapsed rail, a slot wrapper that only holds styles) is skipped
351
+ * instead of being treated as a 0x0 rail. The node is returned because the
352
+ * caller keeps a ResizeObserver pointed at whatever it accepted — the rail is
353
+ * re-rendered (new node) on some open/close transitions.
354
+ *
355
+ * @returns {{node: Element, box: {left: number, top: number, width: number, height: number}}|null}
356
+ */
357
+ function findRail(scope) {
358
+ if (scope === null || scope === undefined || typeof scope.querySelector !== 'function') return null
359
+ for (const selector of RAIL_SELECTORS) {
360
+ let node = null
361
+ try { node = scope.querySelector(selector) } catch { node = null }
362
+ if (node === null || typeof node.getBoundingClientRect !== 'function') continue
363
+ let box = null
364
+ try { box = dockedBodyRect(node) } catch { box = null }
365
+ if (railOnScreen(box)) return { node, box }
366
+ }
367
+ return null
368
+ }
369
+
370
+ /**
371
+ * The rectangle a side card occupies when the right rail is on screen.
372
+ *
373
+ * Pure (no DOM) so the runtime and the tests share one implementation: the
374
+ * caller passes the rail's measured box. `null` means "do not dock" — the
375
+ * rail is missing, unmeasurable, or too small to be worth a page, and the
376
+ * caller keeps the floating presentation instead.
377
+ *
378
+ * Nothing is ever allowed to spill outside the frame: the rectangle is
379
+ * clamped to `[0, frameWidth] × [0, frameHeight]` and `clipped` reports
380
+ * whether that clamp actually bit (a rail straddling the frame edge).
381
+ *
382
+ * @param {{railLeft: number, railTop: number, railWidth: number, railHeight: number,
383
+ * frameWidth?: number|null, frameHeight?: number|null, enabled?: boolean}} input
384
+ * @returns {{left: number, top: number, width: number, height: number,
385
+ * clipped: boolean}|null}
386
+ */
387
+ function railDock({ railLeft, railTop, railWidth, railHeight, frameWidth = null, frameHeight = null, enabled = true }) {
388
+ if (enabled !== true) return null
389
+ if (!Number.isFinite(railLeft) || !Number.isFinite(railTop)) return null
390
+ const width = Number.isFinite(railWidth) ? Math.floor(railWidth) : 0
391
+ const height = Number.isFinite(railHeight) ? Math.floor(railHeight) : 0
392
+ if (width < DOCK_MIN_WIDTH || height < DOCK_MIN_HEIGHT) return null
393
+ const frameW = Number.isFinite(frameWidth) ? frameWidth : railLeft + width
394
+ const frameH = Number.isFinite(frameHeight) ? frameHeight : railTop + height
395
+ const left = Math.max(0, Math.min(Math.floor(railLeft), Math.floor(frameW)))
396
+ const top = Math.max(0, Math.min(Math.floor(railTop), Math.floor(frameH)))
397
+ const dockWidth = Math.max(0, Math.min(width, Math.floor(frameW - left)))
398
+ const dockHeight = Math.max(0, Math.min(height, Math.floor(frameH - top)))
399
+ if (dockWidth < DOCK_MIN_WIDTH || dockHeight < DOCK_MIN_HEIGHT) return null
400
+ // `clipped` means "what was painted is not what was measured": either the
401
+ // size shrank, or the origin had to be pulled back inside the frame (a rail
402
+ // still animating in can measure negative). Callers surface it so a
403
+ // surprising rectangle is explainable instead of mysterious.
404
+ return {
405
+ left,
406
+ top,
407
+ width: dockWidth,
408
+ height: dockHeight,
409
+ clipped: dockWidth < width || dockHeight < height
410
+ || left !== Math.floor(railLeft) || top !== Math.floor(railTop),
411
+ }
412
+ }
413
+
253
414
  // ─────────────────────────────────────────────────────────────────────
254
415
  // §2 i18n
255
416
  // ─────────────────────────────────────────────────────────────────────
@@ -327,8 +488,9 @@ window.__ModuleLoader__.load({
327
488
  setTriggerBoth: '按钮 + 快捷键',
328
489
  setCarrier: '默认作答位置',
329
490
  setSurface: '侧边卡片承载面',
330
- setSurfaceAuto: '自动选择',
491
+ setSurfaceAuto: '自动选择(插件 → 内置卡片)',
331
492
  setSurfaceFlow: '内置浮层卡片',
493
+ surfaceNativeForce: '右侧栏卡片(强制,不参与自动)',
332
494
  setMaxChars: '最大字符数',
333
495
  setMaxCharsHint: '超过部分截断并在提问中标注',
334
496
  setFloatMode: '浮层默认形态',
@@ -338,6 +500,9 @@ window.__ModuleLoader__.load({
338
500
  setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
339
501
  setFloatMaxWidth: '浮层宽度上限(px)',
340
502
  setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
503
+ setDockRail: '右侧栏打开时占满它',
504
+ setDockRailHint: '答案直接铺满右侧栏(读得像栏内的一页),收起为胶囊或关闭即恢复原状;右侧栏不在时不生效',
505
+ floatDocked: '已占满右侧栏',
341
506
  floatExpand: '展开侧边卡片',
342
507
  floatCollapse: '收起为胶囊(不遮挡对话)',
343
508
  floatHide: '隐藏浮层(只留小圆点)',
@@ -564,8 +729,9 @@ window.__ModuleLoader__.load({
564
729
  setTriggerBoth: 'Button + shortcut',
565
730
  setCarrier: 'Default answer carrier',
566
731
  setSurface: 'Side-card surface',
567
- setSurfaceAuto: 'Automatic',
732
+ setSurfaceAuto: 'Automatic (plugin, then built-in)',
568
733
  setSurfaceFlow: 'Built-in floating card',
734
+ surfaceNativeForce: 'Right rail (forced, excluded from automatic)',
569
735
  setMaxChars: 'Max characters',
570
736
  setMaxCharsHint: 'Longer selections are truncated and marked in the question',
571
737
  setFloatMode: 'Floating layer default',
@@ -575,6 +741,9 @@ window.__ModuleLoader__.load({
575
741
  setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
576
742
  setFloatMaxWidth: 'Floating layer max width (px)',
577
743
  setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
744
+ setDockRail: 'Fill the right rail while it is open',
745
+ 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',
746
+ floatDocked: 'Filled the right rail',
578
747
  floatExpand: 'Expand the side cards',
579
748
  floatCollapse: 'Collapse to a capsule (stops covering the conversation)',
580
749
  floatHide: 'Hide the layer (leaves a small launcher)',
@@ -1839,12 +2008,20 @@ window.__ModuleLoader__.load({
1839
2008
  * @returns {{adapter: object, fellBack: boolean}}
1840
2009
  */
1841
2010
  function pickSurface(wanted) {
1842
- const order = [surfaces.native, surfaces.better, surfaces.flow]
2011
+ // `auto` deliberately skips the shipped right rail. A card re-hosted as a
2012
+ // rail tab is a tab the plugin does not own: it outlives the card (the
2013
+ // rail keeps an empty tab reading "this card was closed"), it competes
2014
+ // with the user's own rail tabs, and where it lands depends on a
2015
+ // service-surface detail we do not control. The order asked for is: the
2016
+ // side-card plugin first, the built-in card second. The rail stays
2017
+ // reachable, but only by an explicit `native-rightbar` choice.
2018
+ const autoOrder = [surfaces.better, surfaces.flow]
2019
+ const all = [surfaces.native, surfaces.better, surfaces.flow]
1843
2020
  if (wanted === 'auto' || wanted === undefined) {
1844
- for (const adapter of order) if (adapter.available()) return { adapter, fellBack: false }
2021
+ for (const adapter of autoOrder) if (adapter.available()) return { adapter, fellBack: false }
1845
2022
  return { adapter: surfaces.flow, fellBack: false }
1846
2023
  }
1847
- const match = order.find(adapter => adapter.kind === wanted || adapter === surfaces[wanted])
2024
+ const match = all.find(adapter => adapter.kind === wanted || adapter === surfaces[wanted])
1848
2025
  if (match === undefined) return { adapter: surfaces.flow, fellBack: true }
1849
2026
  if (!match.available()) return { adapter: surfaces.flow, fellBack: true }
1850
2027
  return { adapter: match, fellBack: false }
@@ -1919,13 +2096,18 @@ window.__ModuleLoader__.load({
1919
2096
  trigger drops its own fixed positioning, chips join the row directly. */
1920
2097
  .dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
1921
2098
  .dsa-quickbar-chips{display:contents}
1922
- /* Contrast pass, driven by a light-theme reference screenshot: every 1px edge
1923
- inside the popover measured only ≈1.3–1.7:1 against the card surface, which
1924
- is why the card read as a translucent sticker instead of a framed popover.
1925
- The frame is therefore drawn explicitly here rather than through border-l2,
1926
- which the host resolves below that level; mid grey is used instead of a
1927
- black/white alpha so the stroke stays visible on both themes. The shadow is
1928
- a two-layer falloff so the card lifts off the page as well. */
2099
+ /* Contrast pass on the popover frame. Measured against the card surface with the
2100
+ bundled skin's tokens (bg-base .55 over white → bg-overlay .98): the inner 1px
2101
+ edges drawn with border-l1 sit at ≈1.40:1, which is why the card read as a
2102
+ translucent sticker rather than a framed popover. The outer frame drawn with
2103
+ border-l2 was already ≈1.95:1 (light) / ≈2.88:1 (dark), so swapping it for mid
2104
+ grey is a *hue* choice, not a contrast gain: mid grey .55 measures ≈1.96:1
2105
+ light / ≈2.12:1 dark — neutral instead of blue-on-blue, and slightly weaker in
2106
+ dark. Explicit mid grey is still used over border-l2 because the host may
2107
+ resolve that alias to the surrounding fill; if a genuinely stronger frame is
2108
+ wanted, grey .85 measures ≈3.03:1. The two-layer shadow is what lifts the card
2109
+ off the page; secondary text inside the popover uses label-primary (6.11:1)
2110
+ rather than label-secondary. */
1929
2111
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
1930
2112
  padding:12px;border-radius:12px;border:1px solid rgba(127,127,127,.55);
1931
2113
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -1990,6 +2172,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"],
1990
2172
  html[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover,
1991
2173
  body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
1992
2174
  color:#0a1428;text-shadow:0 1px 0 rgba(255,255,255,.25)}
2175
+ /* The same switch is reused on the settings page, inside a grid cell that can be
2176
+ narrower than the popover: tighten the chips there, never let the pair spill out
2177
+ of its cell, and keep the rail hugging the chips (dsa-field is a stretch
2178
+ column, which would otherwise paint a 400px-wide rail around two small chips). */
2179
+ .dsa-field .dsa-seg{max-width:100%;align-self:flex-start}
2180
+ .dsa-field .dsa-seg button{padding:4px 10px}
1993
2181
  .dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
1994
2182
  border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1995
2183
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -2001,6 +2189,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
2001
2189
  .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
2002
2190
  display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
2003
2191
  .dsa-float > *{pointer-events:auto}
2192
+ /* Docked presentation (1.8.3): the same container, but pinned to the right
2193
+ rail's measured rectangle (inline left/top/width/height) instead of the
2194
+ frame's bottom-right corner. Right/bottom must be released, otherwise the
2195
+ base rule's right:8px/bottom:16px would over-constrain the box. The rail's
2196
+ resize handle is covered while docked — collapsing to the capsule (⇥) or
2197
+ closing gives it back. */
2198
+ .dsa-float[data-dsa-float-mode="dock"]{right:auto;bottom:auto;max-width:none;pointer-events:auto;
2199
+ border-left:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
2200
+ background:var(--dsw-alias-bg-overlay, #fff);overflow:hidden}
2201
+ .dsa-float[data-dsa-float-mode="dock"] .dsa-stack{max-height:none;height:100%;min-height:0;overflow:hidden}
2202
+ .dsa-float[data-dsa-float-mode="dock"] .dsa-card{height:100%;min-height:0;max-height:none;border:0;border-radius:0;
2203
+ box-shadow:none;background:transparent;padding:10px 12px 8px}
2204
+ .dsa-float[data-dsa-float-mode="dock"] .dsa-answer{max-height:none;flex:1 1 auto;min-height:0;overflow:auto}
2004
2205
  .dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
2005
2206
  .dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
2006
2207
  padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
@@ -3818,11 +4019,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3818
4019
  */
3819
4020
  function FloatingLayer({ cards, actions }) {
3820
4021
  const containerRef = React.useRef(null)
4022
+ const dockRef = React.useRef(null)
3821
4023
  const state = useStoreState()
4024
+ const [dock, setDock] = React.useState(null)
3822
4025
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
3823
4026
  const total = visible.length
3824
4027
  const mode = state.floatMode ?? state.config.floatMode ?? CLIENT_DEFAULTS.floatMode
3825
4028
  const maxWidth = state.config.floatMaxWidth ?? CLIENT_DEFAULTS.floatMaxWidth
4029
+ const dockWanted = state.config.dockRail !== false
3826
4030
 
3827
4031
  // Measure the conversation column + right rail, then pin the layer's
3828
4032
  // width to what is left. Recomputed on frame resize and on rail toggles
@@ -3833,6 +4037,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3833
4037
  const node = containerRef.current
3834
4038
  if (node === null || node === undefined) return undefined
3835
4039
  const selector = '[data-slot^="conversation"], .rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]'
4040
+ const observed = new Set()
3836
4041
  const all = () => {
3837
4042
  try {
3838
4043
  return typeof document.querySelectorAll === 'function'
@@ -3844,20 +4049,73 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3844
4049
  try { return typeof document.querySelector === 'function' ? document.querySelector(query) : null } catch { return null }
3845
4050
  }
3846
4051
  let frame = 0
4052
+ let observer = null
4053
+ let mutation = null
4054
+ let observedRail = null
4055
+ const timers = []
3847
4056
  const apply = () => {
3848
4057
  if (typeof node.style?.setProperty !== 'function') return
3849
4058
  const lane = one('[data-slot^="conversation"]')
3850
- const rail = one('.rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]')
3851
4059
  const rootWidth = document.documentElement?.clientWidth ?? window.innerWidth
3852
- const laneRight = lane === null
3853
- ? rootWidth
3854
- : lane.getBoundingClientRect().right
3855
- const railLeft = rail === null ? null : rail.getBoundingClientRect().left
4060
+ const rootHeight = document.documentElement?.clientHeight ?? window.innerHeight
4061
+ const measure = target => {
4062
+ if (target === null || target === undefined) return null
4063
+ try {
4064
+ const rect = typeof target.getBoundingClientRect === 'function'
4065
+ ? target.getBoundingClientRect()
4066
+ : null
4067
+ return rect === null ? null : rect
4068
+ } catch { return null }
4069
+ }
4070
+ const laneRect = measure(lane)
4071
+ const rail = findRail(document)
4072
+ const railBox = rail === null ? null : rail.box
4073
+ const laneRight = laneRect === null ? rootWidth : laneRect.right
4074
+ const railLeft = railBox === null ? null : railBox.left
3856
4075
  const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
3857
4076
  node.style.setProperty('--dsa-float-w', `${placement.width}px`)
3858
4077
  node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
3859
4078
  // Readable placement, so a user report can say WHERE the layer sat.
3860
4079
  node.dataset.layerWidth = String(placement.width)
4080
+
4081
+ // Docked presentation: the card fills the rail page's body instead of
4082
+ // floating over it. Only measured in `full` mode — a capsule/launcher
4083
+ // is deliberately small and must never take the rail over.
4084
+ const next = railBox === null || mode !== 'full'
4085
+ ? null
4086
+ : railDock({
4087
+ railLeft: railBox.left,
4088
+ railTop: railBox.top,
4089
+ railWidth: railBox.width,
4090
+ railHeight: railBox.height,
4091
+ frameWidth: rootWidth,
4092
+ frameHeight: rootHeight,
4093
+ enabled: dockWanted,
4094
+ })
4095
+ const prev = dockRef.current
4096
+ const same = prev === next || (prev !== null && next !== null
4097
+ && prev.left === next.left && prev.top === next.top
4098
+ && prev.width === next.width && prev.height === next.height
4099
+ && prev.clipped === next.clipped)
4100
+ if (!same) {
4101
+ dockRef.current = next
4102
+ setDock(next)
4103
+ }
4104
+ // Why the layer is (not) docked, readable from DevTools. A rail that
4105
+ // exists but is too narrow reads as `rail-too-small`, which is a
4106
+ // different story from `no-rail` and is what a bug report needs.
4107
+ node.dataset.dsaDock = next !== null
4108
+ ? 'dock'
4109
+ : (mode !== 'full'
4110
+ ? 'mode'
4111
+ : (dockWanted ? (railBox === null ? 'no-rail' : 'rail-too-small') : 'off'))
4112
+ // The rail can appear AFTER this effect ran (opening it mounts new
4113
+ // nodes), and it can be re-created later: keep the observer pointed at
4114
+ // whichever node is currently accepted.
4115
+ if (observer !== null && rail !== null && rail.node !== observedRail) {
4116
+ observedRail = rail.node
4117
+ try { observer.observe(rail.node) } catch { /* not observable */ }
4118
+ }
3861
4119
  }
3862
4120
  const schedule = () => {
3863
4121
  if (typeof window.requestAnimationFrame !== 'function') { apply(); return }
@@ -3868,7 +4126,6 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3868
4126
  })
3869
4127
  }
3870
4128
  apply()
3871
- let observer = null
3872
4129
  if (typeof window.ResizeObserver === 'function') {
3873
4130
  observer = new window.ResizeObserver(schedule)
3874
4131
  for (const target of all()) {
@@ -3876,12 +4133,35 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3876
4133
  }
3877
4134
  }
3878
4135
  if (typeof window.addEventListener === 'function') window.addEventListener('resize', schedule)
4136
+ // Opening/closing the rail changes no size this effect already watches,
4137
+ // so the DOM itself has to be the trigger. Attributes only (no
4138
+ // childList): streaming tokens rewrite text nodes continuously and must
4139
+ // not wake the layer on every frame.
4140
+ if (typeof window.MutationObserver === 'function' && document.body !== null && document.body !== undefined) {
4141
+ try {
4142
+ mutation = new window.MutationObserver(schedule)
4143
+ mutation.observe(document.body, {
4144
+ attributes: true,
4145
+ subtree: true,
4146
+ attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active', 'style', 'class'],
4147
+ })
4148
+ } catch { mutation = null }
4149
+ }
4150
+ // The rail slides in; a rect taken mid-animation is wrong. Re-measure a
4151
+ // few times right after a card/mode change, then stop for good.
4152
+ if (typeof window.setTimeout === 'function') {
4153
+ for (const delay of [80, 200, 420, 800]) timers.push(window.setTimeout(schedule, delay))
4154
+ }
3879
4155
  return () => {
3880
4156
  if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', schedule)
3881
4157
  if (observer !== null) { try { observer.disconnect() } catch { /* already gone */ } }
4158
+ if (mutation !== null) { try { mutation.disconnect() } catch { /* already gone */ } }
4159
+ for (const timer of timers) {
4160
+ if (typeof window.clearTimeout === 'function') window.clearTimeout(timer)
4161
+ }
3882
4162
  if (frame !== 0 && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame)
3883
4163
  }
3884
- }, [maxWidth, total, mode])
4164
+ }, [maxWidth, total, mode, dockWanted])
3885
4165
 
3886
4166
  if (total === 0) return null
3887
4167
 
@@ -3894,13 +4174,47 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3894
4174
  : t('floatDone', { n: total })
3895
4175
  const firstQuestion = visible[0].question
3896
4176
 
4177
+ // Docked geometry is only meaningful in `full` mode; the effect nulls it
4178
+ // out otherwise, and this guard keeps a stale rect from a previous mode
4179
+ // from being painted for one frame.
4180
+ const docked = mode === 'full' && dock !== null
4181
+
3897
4182
  const container = children => h('div', {
3898
- className: 'dsa-float',
4183
+ className: docked ? 'dsa-float dsa-dock' : 'dsa-float',
3899
4184
  'data-dsa-root': '',
3900
4185
  ref: containerRef,
3901
- 'data-dsa-float-mode': mode,
4186
+ 'data-dsa-float-mode': docked ? 'dock' : mode,
4187
+ ...(docked
4188
+ ? {
4189
+ style: {
4190
+ left: `${dock.left}px`,
4191
+ top: `${dock.top}px`,
4192
+ width: `${dock.width}px`,
4193
+ height: `${dock.height}px`,
4194
+ },
4195
+ }
4196
+ : null),
3902
4197
  }, children)
3903
4198
 
4199
+ const cardNodes = visible.map(card => h(AnswerCard, {
4200
+ key: card.id,
4201
+ card,
4202
+ surfaceLabel: null,
4203
+ onCopy: element => actions.copy(card, element),
4204
+ onFollowUp: text => actions.followUp(card, text),
4205
+ onRetry: () => actions.retry(card),
4206
+ onRetryEdit: text => actions.retryEdit(card, text),
4207
+ onCancel: () => actions.cancel(card),
4208
+ onSendToMain: () => actions.sendToMain(card),
4209
+ onClose: () => actions.close(card),
4210
+ onLocate: target => actions.locateCard(target ?? card),
4211
+ onExport: target => actions.exportCard(target ?? card),
4212
+ onInsert: target => actions.insertAnswer(target ?? card),
4213
+ onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
4214
+ onViewVersion: index => actions.viewVersion(card, index),
4215
+ onFloatMode: setFloatMode,
4216
+ }))
4217
+
3904
4218
  if (mode === 'off') {
3905
4219
  return container(h('button', {
3906
4220
  type: 'button',
@@ -3921,25 +4235,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
3921
4235
  h('span', { className: 'dsa-capsule-chev' }, '›')))
3922
4236
  }
3923
4237
 
3924
- return container(h('div', { className: 'dsa-stack' },
3925
- visible.map(card => h(AnswerCard, {
3926
- key: card.id,
3927
- card,
3928
- surfaceLabel: null,
3929
- onCopy: element => actions.copy(card, element),
3930
- onFollowUp: text => actions.followUp(card, text),
3931
- onRetry: () => actions.retry(card),
3932
- onRetryEdit: text => actions.retryEdit(card, text),
3933
- onCancel: () => actions.cancel(card),
3934
- onSendToMain: () => actions.sendToMain(card),
3935
- onClose: () => actions.close(card),
3936
- onLocate: target => actions.locateCard(target ?? card),
3937
- onExport: target => actions.exportCard(target ?? card),
3938
- onInsert: target => actions.insertAnswer(target ?? card),
3939
- onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
3940
- onViewVersion: index => actions.viewVersion(card, index),
3941
- onFloatMode: setFloatMode,
3942
- }))))
4238
+ // Docked: the card takes the rail's whole rectangle, so it reads as one of
4239
+ // the rail's pages rather than a 340px card hovering over it. The card
4240
+ // keeps its own header (title + badges + collapse/hide controls), which is
4241
+ // what makes the page dismissible without leaving the rail.
4242
+ return container(h('div', {
4243
+ className: 'dsa-stack',
4244
+ 'data-dsa-dock-body': docked ? String(dock.width) : undefined,
4245
+ }, cardNodes))
3943
4246
  }
3944
4247
 
3945
4248
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
@@ -4016,7 +4319,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4016
4319
  else toast(t('notifyGranted'))
4017
4320
  }
4018
4321
 
4019
- const field = (label, control, hint) => h('div', { className: 'dsa-field' },
4322
+ // `style` is optional: the two-chip carrier switch needs two grid tracks
4323
+ // (`.dsa-grid` fits columns down to 150px, and two nowrap chips need
4324
+ // ~175px), so that field spans two tracks instead of overflowing.
4325
+ const field = (label, control, hint, style) => h('div', { className: 'dsa-field', style },
4020
4326
  h('label', null, label),
4021
4327
  control,
4022
4328
  hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
@@ -4247,6 +4553,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4247
4553
  ? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
4248
4554
  : null)
4249
4555
 
4556
+ // NOT marked `data-dsa-rail-host`: the settings page is not a rail page,
4557
+ // and marking it would let the floating layer dock over the settings panel.
4250
4558
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
4251
4559
  h(Styles, null),
4252
4560
  h('div', null,
@@ -4259,15 +4567,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4259
4567
  ['shortcut', t('setTriggerShortcut')],
4260
4568
  ['both', t('setTriggerBoth')],
4261
4569
  ])),
4262
- field(t('setCarrier'), select('defaultCarrier', [
4263
- ['side', t('carrierSide')],
4264
- ['main', t('carrierMain')],
4265
- ])),
4570
+ field(t('setCarrier'), h(CarrierSeg, {
4571
+ value: draft.defaultCarrier,
4572
+ onChange: id => setDraft({ ...draft, defaultCarrier: id }),
4573
+ }), undefined, { gridColumn: 'span 2' }),
4266
4574
  field(t('setSurface'), select('sideSurface', [
4267
4575
  ['auto', t('setSurfaceAuto')],
4268
- ['flow', t('setSurfaceFlow')],
4269
- ['native-rightbar', t('surfaceNative')],
4270
4576
  ['better-sidebar', t('surfaceBetter')],
4577
+ ['flow', t('setSurfaceFlow')],
4578
+ ['native-rightbar', t('surfaceNativeForce')],
4271
4579
  ])),
4272
4580
  field(t('setZones'), select('captureZones', [
4273
4581
  ['auto', t('setZonesAuto')],
@@ -4347,6 +4655,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4347
4655
  }),
4348
4656
  t('setFollowUps')),
4349
4657
  h('span', { className: 'dsa-muted' }, t('setFollowUpsHint')),
4658
+ h('label', { className: 'dsa-check' },
4659
+ h('input', {
4660
+ type: 'checkbox',
4661
+ checked: draft.dockRail !== false,
4662
+ onChange: event => setDraft({ ...draft, dockRail: event.target.checked }),
4663
+ }),
4664
+ t('setDockRail')),
4665
+ h('span', { className: 'dsa-muted' }, t('setDockRailHint')),
4350
4666
  historySection,
4351
4667
  h('div', { className: 'dsa-row' },
4352
4668
  h('button', {
@@ -5774,7 +6090,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5774
6090
  }
5775
6091
  }, [cardId])
5776
6092
  if (card === undefined) {
5777
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
6093
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
5778
6094
  h(Styles, null),
5779
6095
  h('div', { className: 'dsa-muted' }, t('cardClosed')))
5780
6096
  }
@@ -5785,11 +6101,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5785
6101
  // note for a rail that keeps it.
5786
6102
  try { surfaces.native.close(card) } catch { /* the rail owns its lifecycle */ }
5787
6103
  try { surfaces.better.close(card) } catch { /* the host may already be gone */ }
5788
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
6104
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
5789
6105
  h(Styles, null),
5790
6106
  h('div', { className: 'dsa-muted' }, t('cardMovedToFlow')))
5791
6107
  }
5792
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '', style: { padding: '10px' } },
6108
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
5793
6109
  h(Styles, null),
5794
6110
  h(AnswerCard, {
5795
6111
  card,
@@ -5967,6 +6283,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5967
6283
  renderRichText,
5968
6284
  pickSurface,
5969
6285
  floatPlacement,
6286
+ railDock,
5970
6287
  compareVersions,
5971
6288
  electFamilyLeader,
5972
6289
  findLegacyLayers,
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.8.2'
45
+ export const PLUGIN_VERSION = '1.8.3'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -83,6 +83,17 @@ export const DEFAULT_CONFIG = {
83
83
  floatMode: 'capsule',
84
84
  /** Hard cap, in CSS px, for the floating layer's width. */
85
85
  floatMaxWidth: 340,
86
+ /**
87
+ * Dock the answer card into the right rail when one is on screen, so the
88
+ * answer gets the whole rail instead of a 340px card floating over it.
89
+ *
90
+ * The rail exposes no "add a page" API to plugins on this DSH build (the
91
+ * `sidebarRightTabs` service is absent; see README §6), so the Client half
92
+ * measures the rail's rectangle and paints the card inside it. Turning this
93
+ * off restores the pure floating behaviour; with no rail on screen the
94
+ * floating layer is used either way.
95
+ */
96
+ dockRail: true,
86
97
  /**
87
98
  * One-click quick actions rendered beside the selection trigger.
88
99
  *
@@ -403,7 +414,7 @@ export function sanitizeLayer(raw, problems = []) {
403
414
  if (points >= 0 && points <= MAX_CUSTOM_PERSONA_CHARS) out.customPersona = raw.customPersona
404
415
  else problems.push(`customPersona: 必须是 ≤${MAX_CUSTOM_PERSONA_CHARS} 字符的字符串,已忽略`)
405
416
  }
406
- for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions']) {
417
+ for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions', 'dockRail']) {
407
418
  if (raw[boolKey] === undefined) continue
408
419
  if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
409
420
  else problems.push(`${boolKey}: 必须是布尔值,已忽略`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",