dsh-sidecard-ask 1.8.1 → 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,122 @@
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
+
79
+ ## 1.8.2
80
+
81
+ **主题:提问框底部的「作答位置」从"两枚看不出选中哪一侧的标签"改成一眼可辨的分段芯片;并把 1.8.1 之后写在本地、尚未提交的弹层对比度修补一并发布。**
82
+
83
+ 起因是用户截图:弹层底部「作答位置 | 主对话 | 侧边卡片」两个选项长得几乎一样,看不出答案会去主对话还是侧边卡片。
84
+
85
+ - **选中态不可辨(用户反馈,已复现)**:旧选中态只有 `background: var(--dsw-alias-bg-layer-2)`,
86
+ 叠在弹层 `--dsw-alias-bg-overlay` 上几乎是同色(本机皮肤下 `rgba(243,247,253,.95)` on
87
+ `rgba(248,251,255,.98)`),选中与未选中的差别只剩字重。
88
+ 改法:`.dsa-seg` 变成带中灰描边(`rgba(127,127,127,.5)`)与内凹阴影的轨道;两个选项各带一枚 13px
89
+ 内联 SVG 图标(气泡=主对话,带右轨的框=侧边卡片),**选中侧为品牌实底 + 白色 600 字 + 内白环 + 投影**,
90
+ 未选侧为 `label-secondary` + 透明底;补 `:hover` 与 `:focus-visible`(品牌色 outline)。
91
+ 组件抽成 `CarrierSeg`(client §9),带 `role="group"`、`aria-pressed`、`title` 说明;文案仍走 i18n。
92
+ - **深色主题里白字压品牌色只有 2.79:1(复核时发现的,非用户反馈)**:本机皮肤深色把
93
+ `--dsw-alias-brand-primary` 提亮到 `#5b9cf5`,白色 600 字在其上低于 4.5:1 正文下限、也低于 3:1 大字下限。
94
+ 改法:`[data-ds-dark-theme]` 下选中侧改用深墨 `#0a1428`(实测 6.57:1);浅色保持白字
95
+ (`#2563c0` 上 5.79:1)。令牌值取自本机 `uiskin-theme`,对比度按 WCAG 相对亮度公式实算,不是目测。
96
+ - **弹层边框/阴影修补(1.8.1 之后写在本地、未提交,本次一并发布;含一处数字更正)**:`.dsa-pop` 的 1px 边
97
+ 改显式中灰 `rgba(127,127,127,.55)`,阴影改两层衰减
98
+ `0 14px 36px rgba(0,0,0,.30), 0 3px 10px rgba(0,0,0,.14)`;`.dsa-pop` 内的次级文案改用
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,但视觉更硬,未擅自采用。
106
+ - 测试:contract 新增 **16** 项钉住这个控件(唯一 `.dsa-seg`、恰好两个按钮、恰好一个
107
+ `aria-pressed="true"`、每个选项带一枚内联 `svg`、`data-on` 只有一个真值、两个去向的标签不互换、
108
+ 品牌实底规则与深色覆盖规则必须出现在样式表里)。**点击切换本身仍然不可测**:harness 的
109
+ `useState` 只回队列式 setter、不触发重渲染,注释里写明了原因,没有假装测过。
110
+ - 版本号三处同步到 1.8.2。
111
+
112
+ 本机实测(四项均 exit 0):verify **57** / contract **972** / float-placement **38** / smoke **226**
113
+ = **1293 项全部通过**(1.8.1 为 1277,contract 956 → 972)。
114
+
115
+ > 未验证(如实记录):浅色/深色两张对比图(`design-preview/07-carrier-seg-{light,dark}.png`)是
116
+ > **离屏渲染**——无头 Edge 加载"从 `client.js` 抽取的真实 CSS + 本机皮肤真实令牌值"的静态页,
117
+ > 不是运行中 DSH 窗口的截图。真实页面里的点击手感、字体栅格化差异、弹层背后背景图的影响,
118
+ > 仍需刷新页面人工看一次。此前一轮口头汇报里说"新增 8 条"是数错了,实际新增 16 条。
119
+
3
120
  ## 1.8.1
4
121
 
5
122
  **主题:对 1.8.0 交付做实证复核后的三项修正——1 个已复现的真实缺陷、1 处开关失效、1 条缺证据的声明。**
package/README.md CHANGED
@@ -72,8 +72,10 @@ dsh-sidecard-ask/
72
72
  ├── README.md 本文件
73
73
  ├── CHANGELOG.md 版本变更记录
74
74
  ├── LICENSE MIT
75
- ├── design-preview/ 1.5.0 的方案讨论资料,**不参与发布**:00–02 是手绘 SVG 示意图(非截图),
76
- │ 03-prototype-ac.html 是可直接双击打开的可点按原型(自带近似色板,非真实观感)
75
+ ├── design-preview/ 方案讨论资料,**不参与发布**:00–02 是手绘 SVG 示意图(非截图),
76
+ │ 03-prototype-ac.html 是可直接双击打开的可点按原型(自带近似色板,非真实观感),
77
+ │ 07-carrier-seg-{light,dark}.png 是 1.8.2 的离屏渲染对比图(无头 Edge + 真实 CSS
78
+ │ 与真实令牌值,仍非运行中窗口截图)
77
79
  ├── tools/
78
80
  │ └── compat-probe.mjs DSH 版本能力探测(§7 矩阵的来源)
79
81
  └── test/
@@ -154,10 +156,11 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
154
156
  |---|---|---|---|---|
155
157
  | `trigger` | `selection` \| `shortcut` \| `both` | `selection` | **触发方式**:选中即浮出按钮 / 只用快捷键 / 两者都要 | 改后立即(客户端读 /state) |
156
158
  | `defaultCarrier` | `main` \| `side` | `side` | **默认作答位置**:提问框里仍可临时切换 | 立即 |
157
- | `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 / 强制内置浮层卡片 | 立即 |
158
160
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
159
161
  | `floatMode` | `capsule` \| `full` \| `off` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈 / 只留一个计数圆点。运行时可即时收起、隐藏,但不写回本项——刷新后仍回到这里设的默认 | 立即 |
160
162
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
163
+ | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、关闭浮层、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
161
164
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
162
165
  | `captureZones` | `auto` \| `chat` \| `task` \| `chat+task` | `auto` | 只在哪些区域触发 | 立即 |
163
166
  | `showInUnclassified` | 布尔 | `true` | 无法归类的区域是否也触发 | 立即 |
@@ -200,6 +203,35 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
200
203
  - 挂载时、窗口 resize 时、右栏开合时(`ResizeObserver`)都会重算;流式刷新只重渲染,不动几何。
201
204
  - 浮层根节点带 `data-layer-width`(实际宽度)与 `data-dsa-float-mode`:报告问题时可直接读这两个值。
202
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
+
203
235
  ---
204
236
 
205
237
  ## 五、侧边承载面与"侧边卡片插件"适配
@@ -208,15 +240,19 @@ bleeds = 对话区右边界 - left > 1 // 只有带宽 < 240px 时才
208
240
 
209
241
  | 顺序 | 承载面 | 依赖 | 失败时 |
210
242
  |---|---|---|---|
211
- | 1 | DSH 原生右侧栏 | `ctx.sidebarRightTabs`(注册 tab 类型)+ `ctx.sidebarRight`(`openTab`),并占用槽位 `sidebar.right.pane.tab` / `…tab.title` | 记 `surfaces.native.error`,落到下一层;**类型注册会回滚**(见下) |
212
- | 2 | **dsh-better-sidebar**(侧边卡片插件) | 客户端服务 `ctx.betterSidebar`:`registerTab` + `openTab(seed, scope)`;卡片 id 走 `tab.meta.cardId`,因此**要求其 `features` 含 `tabMeta`** | 同上(缺 `tabMeta` 时记 `no-tab-meta` 并跳过,而不是开一个空 tab) |
213
- | 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'` 才用它;注册失败会回滚(见下) |
214
246
 
215
247
  - 装了 `dsh-better-sidebar`:卡片以它的 tab 形式出现在它的面板里,关闭卡片会同时 `closeTab`,不残留空 tab。
216
- - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是本插件的默认与保底路径**。
248
+ 这是 `auto` 的**第一选择**。
249
+ - 没装:自动使用内置浮层卡片,功能完全一致——**这条路径是第二选择,也是保底路径**。
217
250
  浮层默认是**贴帧右边缘的胶囊**(一行高,只有状态与问题首句),点击才展开成完整卡片,
218
251
  标题栏右侧的「收起 / 隐藏」可随时收回;它的宽度由 `floatPlacement` 计算,
219
252
  只会占用**对话区右侧那条预留带**,不会压住正文(细节见 §4.1)。
253
+ - **原生右侧栏为什么退出 `auto`**:卡片被塞进右栏只是一个 tab,而这个 tab 的生命周期不归插件管——
254
+ 卡片关闭后右栏仍留着一条读作「该卡片已关闭」的空标签,和你自己的右栏标签抢位置,
255
+ 落点还取决于我们并不控制的宿主实现细节。它会保留成一个**强制选项**(下拉里标注「强制,不参与自动」)。
220
256
  - 强制指定了一个不可用的承载面:回退到内置浮层,并在卡片上标注「已回退」。
221
257
 
222
258
  ### 5.1 与 dsh-better-sidebar 0.22.1 的适配核对(2026-09-28)
@@ -278,7 +314,7 @@ better-sidebar 会把收到的每个 tab 描述符**镜像注册进同一个原
278
314
  | `sidecard-ask:card` | 本插件的**原生右侧栏**承载面(1.2.1 之前完全缺失) |
279
315
  | `dsh-better-sidebar:sidecard-ask:card:workbench` | 经 dsh-better-sidebar 镜像注册的承载面(独立 kind) |
280
316
 
281
- 两者共存、均 `active: true` —— 即"同 kind 冲突"已被消除,`auto` 默认走原生右侧栏,强制 `sideSurface: better-sidebar` 仍可用。
317
+ 两者共存、均 `active: true` —— 即"同 kind 冲突"已被消除。`auto` 走的是 **dsh-better-sidebar → 内置浮层卡片**这条链(1.8.3 起原生右侧栏只在显式 `sideSurface: native-rightbar` 时使用),两个强制选项都仍可用。
282
318
 
283
319
  ### 5.3 与 DSH 桌面版(Electron)的适配核对(2026-09-30)
284
320
 
@@ -424,7 +460,7 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
424
460
 
425
461
  | 缺口 | 适配实现 | 在真机/测试里的表现 |
426
462
  |---|---|---|
427
- | 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` 钉住自动链里不出现原生右侧栏 |
428
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` |
429
465
  | ≤0.1.6-alpha.2 没有归档门 | 不再因"父会话已归档"直接拒绝:仍优先挑未归档代理,但只有归档候选时**照常尝试**,并在 `start` 事件里带 `parentArchived:true`;真被门拒绝时把空 turn 的 `refusal` 翻译成 `blocked-step` 并说明原因 | `smoke-test`:全归档组合仍能拿到答案;`refusal` 用例断言 `code=blocked-step` 且文案提到归档 |
430
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'`;同时断言两源并存时文本不重复 |
@@ -515,6 +551,9 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
515
551
  2. 选区末端应浮出动作条:「💬 追问选中内容」按钮 + 「解释 / 翻译 / 总结 / 润色 / 纠错」芯片,
516
552
  按钮带区域徽标(聊天区 / 任务区 / 跨区选择);**点任一芯片即跳过提问框直接在侧边卡片作答**;
517
553
  3. 点主按钮后提问框出现(顶部同样有一排芯片),焦点在输入框,引文预览显示选中文本;
554
+ 底部「作答位置」是两枚带内联图标的芯片(气泡=主对话,带右轨的框=侧边卡片):**选中侧为品牌实底 + 白字 600 + 内白环**,
555
+ 未选侧只有描边;深色主题下选中侧改深墨字(品牌色在深色被提亮到 `#5b9cf5`,白字只有 2.79:1,深墨字 6.57:1);
556
+ 控件带 `role="group"` 与 `aria-pressed`,键盘 Tab 可见品牌色焦点环;
518
557
  4. 输入问题按 Enter → 提问框关闭,右下角出现卡片,状态先为「作答中…」并**逐字增长**;
519
558
  5. 结束后状态变「已完成」;底部出现「改问 / 复制 / 继续追问 / 关闭」;答案里代码块的右上角有「⧉ 复制代码」;
520
559
  6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题(追加一轮),卡片内容**重置换行**后继续流式;
@@ -559,6 +598,14 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
559
598
  **关键判据:胶囊/卡片的左边缘不得越过对话区(`[data-slot^="conversation"]`)的右边界**。
560
599
  把窗口从 1920px 拖到 900px、再开关一次右侧栏,浮层应随之变窄而不是往左压住正文;
561
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
+ 设置页取消「右侧栏打开时占满它」并保存 → 同一操作不再铺满。
562
609
 
563
610
  **失败信号**:卡片停在「作答中…」不动 = SSE 帧没到达(看 console 是否有 `/sidecard-ask/api/ask` 报错);`done.streaming=false` = 该版本没有流式帧(属预期降级,卡片会写明)。
564
611
 
@@ -599,9 +646,13 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
599
646
  ```
600
647
 
601
648
  四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
602
- 当前规模(1.8.1 本机实测,四项均 exit 0):verify **57** 项 + contract **956** 项 + float-placement **38** 项 +
603
- smoke **226** 项 = **1277 项全部通过**(1.8.0 为 1267 项,其中 contract 由 946 → 956:
604
- 新增"清空历史后重载仍为空"与"建议芯片跟随开关"两组回归用例)。
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。
605
656
 
606
657
  **真机探针(不属于四套自测)**:`.compat-report/scripts/composer-probe.mjs` 需要
607
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.1'
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,10 +2096,22 @@ 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}
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. */
1922
2111
  .dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
1923
- padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
2112
+ padding:12px;border-radius:12px;border:1px solid rgba(127,127,127,.55);
1924
2113
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
1925
- box-shadow:0 10px 30px rgba(0,0,0,.24);font-size:13px;line-height:19px}
2114
+ box-shadow:0 14px 36px rgba(0,0,0,.30),0 3px 10px rgba(0,0,0,.14);font-size:13px;line-height:19px}
1926
2115
  .dsa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1927
2116
  .dsa-title{font-weight:600;font-size:13px}
1928
2117
  .dsa-quote{margin:0;padding:6px 8px;max-height:96px;overflow:auto;white-space:pre-wrap;word-break:break-word;
@@ -1946,11 +2135,49 @@ window.__ModuleLoader__.load({
1946
2135
  /* Icon-only variant (the floating layer's collapse/hide controls): square and
1947
2136
  quiet so the card title bar stays one line and the title keeps the weight. */
1948
2137
  .dsa-btn-icon{padding:4px 7px;line-height:1;font-size:13px;color:var(--dsw-alias-label-secondary, #666)}
1949
- .dsa-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;overflow:hidden}
1950
- .dsa-seg button{padding:3px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary, #555);
1951
- font:inherit;font-size:12px;cursor:pointer}
1952
- .dsa-seg button[data-on="true"]{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14));
1953
- color:var(--dsw-alias-label-primary, #111);font-weight:500}
2138
+ /* Segmented control (main chat / side card). These are the two destinations an
2139
+ answer can land in, so the pair has to read as a radio choice, not as two
2140
+ quiet labels: the group gets a recessed track with an explicit mid-grey edge
2141
+ (border-l1/l2 measured ≈1.3:1 against the popover surface and disappeared),
2142
+ each option carries a 13px label plus an inline glyph, and the selected side
2143
+ is a solid brand chip whose white 600-weight text cannot be confused with the
2144
+ unselected side. The inner white ring keeps the chip from flattening on the
2145
+ brand colour in either theme. */
2146
+ .dsa-seg{display:inline-flex;padding:3px;gap:3px;
2147
+ border:1px solid rgba(127,127,127,.5);border-radius:10px;
2148
+ background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.10));
2149
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.10)}
2150
+ .dsa-seg button{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;
2151
+ border:1px solid transparent;border-radius:7px;background:transparent;
2152
+ color:var(--dsw-alias-label-secondary, #666);font:inherit;font-size:13px;font-weight:500;
2153
+ line-height:18px;cursor:pointer;white-space:nowrap;
2154
+ transition:background .12s ease,color .12s ease,box-shadow .12s ease}
2155
+ .dsa-seg button svg{flex:none;opacity:.8}
2156
+ .dsa-seg button:hover{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.16));
2157
+ color:var(--dsw-alias-label-primary, #222)}
2158
+ .dsa-seg button:hover svg{opacity:1}
2159
+ .dsa-seg button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
2160
+ .dsa-seg button[data-on="true"]{background:var(--dsw-alias-brand-primary, #4c8dff);
2161
+ border-color:var(--dsw-alias-brand-primary, #4c8dff);color:#fff;font-weight:600;
2162
+ box-shadow:0 1px 4px rgba(0,0,0,.22),inset 0 0 0 1px rgba(255,255,255,.22)}
2163
+ .dsa-seg button[data-on="true"] svg{opacity:1}
2164
+ .dsa-seg button[data-on="true"]:hover{background:var(--dsw-alias-brand-primary, #4c8dff);color:#fff}
2165
+ /* Dark themes lighten the brand accent (the local skin goes #2563c0 -> #5b9cf5),
2166
+ where white 600 text on the chip measures 2.79:1 — below the 4.5:1 floor for
2167
+ body text and below even the 3:1 large-text floor. Invert the on-brand label
2168
+ there instead of betting on one fixed foreground.
2169
+ DSH flags dark mode with [data-ds-dark-theme] on the root element. */
2170
+ html[data-ds-dark-theme] .dsa-seg button[data-on="true"],
2171
+ body[data-ds-dark-theme] .dsa-seg button[data-on="true"],
2172
+ html[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover,
2173
+ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
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}
1954
2181
  .dsa-card{pointer-events:auto;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:12px;
1955
2182
  border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
1956
2183
  background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
@@ -1962,6 +2189,19 @@ window.__ModuleLoader__.load({
1962
2189
  .dsa-float{pointer-events:none;position:fixed;right:var(--dsa-gap-right, 8px);bottom:16px;
1963
2190
  display:flex;flex-direction:column;gap:8px;width:var(--dsa-float-w, 340px);max-width:92vw}
1964
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}
1965
2205
  .dsa-stack{display:flex;flex-direction:column;gap:8px;max-height:52vh;overflow:auto}
1966
2206
  .dsa-capsule{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;text-align:left;
1967
2207
  padding:8px 12px;border-radius:999px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;
@@ -2014,6 +2254,13 @@ window.__ModuleLoader__.load({
2014
2254
  .dsa-code{padding:0 4px;border-radius:4px;background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));
2015
2255
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
2016
2256
  .dsa-muted{color:var(--dsw-alias-label-secondary, #666);font-size:11px;line-height:16px}
2257
+ /* Same contrast pass, applied to the popover's body copy: label-secondary
2258
+ measures ≈6:1 here and reads washed out at 11–13px. Secondary text inside
2259
+ .dsa-pop is re-derived from the primary label token instead, held one step
2260
+ quieter than the title through opacity so the hierarchy survives. */
2261
+ .dsa-pop .dsa-quote,
2262
+ .dsa-pop .dsa-muted{color:var(--dsw-alias-label-primary, #111)}
2263
+ .dsa-pop .dsa-muted{opacity:.78}
2017
2264
  .dsa-error{color:var(--dsw-alias-state-error-primary, #c0392b);font-size:12px}
2018
2265
  .dsa-toast{pointer-events:auto;position:fixed;left:50%;transform:translateX(-50%);bottom:24px;padding:6px 12px;
2019
2266
  border-radius:999px;font-size:12px;background:var(--dsw-alias-bg-overlay, #222);
@@ -3260,6 +3507,43 @@ window.__ModuleLoader__.load({
3260
3507
  }, h('span', null, action.label))))
3261
3508
  }
3262
3509
 
3510
+ /** Where the answer should land: the main conversation, or an independent
3511
+ * side card. Rendered as a two-chip switch with an inline glyph per option
3512
+ * so the pair is scannable at a glance, and the selected chip is painted
3513
+ * with the brand fill (see .dsa-seg) instead of a whisper-grey tint. The
3514
+ * hint text moves into title/aria-label rather than a third line, because
3515
+ * the popover already has quote + chips + composer and a longer hint would
3516
+ * push the submit row out of view. */
3517
+ function CarrierSeg({ value, onChange }) {
3518
+ const glyph = path => h('svg', {
3519
+ viewBox: '0 0 16 16', width: 13, height: 13, 'aria-hidden': 'true', focusable: 'false',
3520
+ }, h('path', {
3521
+ d: path, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3,
3522
+ strokeLinejoin: 'round', strokeLinecap: 'round',
3523
+ }))
3524
+ const options = [
3525
+ // speech balloon = the main conversation
3526
+ { id: 'main', label: t('carrierMain'), hint: t('carrierMainHint'),
3527
+ icon: glyph('M2.5 3.5h11v8H8.4L5.2 14.3V11.5H2.5z') },
3528
+ // frame with a rail on the right = a card docked in the side panel
3529
+ { id: 'side', label: t('carrierSide'), hint: t('carrierSideHint'),
3530
+ icon: glyph('M2.5 3.5h11v9h-11zM10 3.5v9') },
3531
+ ]
3532
+ return h('div', {
3533
+ className: 'dsa-seg', role: 'group', 'aria-label': t('carrier'),
3534
+ }, options.map(option => {
3535
+ const on = value === option.id
3536
+ return h('button', {
3537
+ key: option.id,
3538
+ type: 'button',
3539
+ 'data-on': String(on),
3540
+ 'aria-pressed': String(on),
3541
+ title: option.hint,
3542
+ onClick: () => onChange(option.id),
3543
+ }, option.icon, option.label)
3544
+ }))
3545
+ }
3546
+
3263
3547
  /** The question popover. */
3264
3548
  function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
3265
3549
  const [question, setQuestion] = React.useState('')
@@ -3341,19 +3625,7 @@ window.__ModuleLoader__.load({
3341
3625
  }),
3342
3626
  h('div', { className: 'dsa-row' },
3343
3627
  h('span', { className: 'dsa-muted' }, t('carrier')),
3344
- h('div', { className: 'dsa-seg' },
3345
- h('button', {
3346
- type: 'button',
3347
- 'data-on': String(carrier === 'main'),
3348
- title: t('carrierMainHint'),
3349
- onClick: () => setCarrier('main'),
3350
- }, t('carrierMain')),
3351
- h('button', {
3352
- type: 'button',
3353
- 'data-on': String(carrier === 'side'),
3354
- title: t('carrierSideHint'),
3355
- onClick: () => setCarrier('side'),
3356
- }, t('carrierSide')))),
3628
+ h(CarrierSeg, { value: carrier, onChange: setCarrier })),
3357
3629
  h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
3358
3630
  h('span', { className: 'dsa-muted', style: { marginRight: 'auto' } },
3359
3631
  t('askShortcutHint', { key: config.shortcut })),
@@ -3747,11 +4019,14 @@ window.__ModuleLoader__.load({
3747
4019
  */
3748
4020
  function FloatingLayer({ cards, actions }) {
3749
4021
  const containerRef = React.useRef(null)
4022
+ const dockRef = React.useRef(null)
3750
4023
  const state = useStoreState()
4024
+ const [dock, setDock] = React.useState(null)
3751
4025
  const visible = cards.filter(card => card.surface === 'flow' && card.open)
3752
4026
  const total = visible.length
3753
4027
  const mode = state.floatMode ?? state.config.floatMode ?? CLIENT_DEFAULTS.floatMode
3754
4028
  const maxWidth = state.config.floatMaxWidth ?? CLIENT_DEFAULTS.floatMaxWidth
4029
+ const dockWanted = state.config.dockRail !== false
3755
4030
 
3756
4031
  // Measure the conversation column + right rail, then pin the layer's
3757
4032
  // width to what is left. Recomputed on frame resize and on rail toggles
@@ -3762,6 +4037,7 @@ window.__ModuleLoader__.load({
3762
4037
  const node = containerRef.current
3763
4038
  if (node === null || node === undefined) return undefined
3764
4039
  const selector = '[data-slot^="conversation"], .rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]'
4040
+ const observed = new Set()
3765
4041
  const all = () => {
3766
4042
  try {
3767
4043
  return typeof document.querySelectorAll === 'function'
@@ -3773,20 +4049,73 @@ window.__ModuleLoader__.load({
3773
4049
  try { return typeof document.querySelector === 'function' ? document.querySelector(query) : null } catch { return null }
3774
4050
  }
3775
4051
  let frame = 0
4052
+ let observer = null
4053
+ let mutation = null
4054
+ let observedRail = null
4055
+ const timers = []
3776
4056
  const apply = () => {
3777
4057
  if (typeof node.style?.setProperty !== 'function') return
3778
4058
  const lane = one('[data-slot^="conversation"]')
3779
- const rail = one('.rightbar, [data-slot^="sidebar.right"], [data-dsh-panel-host]')
3780
4059
  const rootWidth = document.documentElement?.clientWidth ?? window.innerWidth
3781
- const laneRight = lane === null
3782
- ? rootWidth
3783
- : lane.getBoundingClientRect().right
3784
- 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
3785
4075
  const placement = floatPlacement({ frameWidth: rootWidth, laneRight, railLeft, maxWidth })
3786
4076
  node.style.setProperty('--dsa-float-w', `${placement.width}px`)
3787
4077
  node.style.setProperty('--dsa-gap-right', `${FLOAT_GAP_RIGHT}px`)
3788
4078
  // Readable placement, so a user report can say WHERE the layer sat.
3789
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
+ }
3790
4119
  }
3791
4120
  const schedule = () => {
3792
4121
  if (typeof window.requestAnimationFrame !== 'function') { apply(); return }
@@ -3797,7 +4126,6 @@ window.__ModuleLoader__.load({
3797
4126
  })
3798
4127
  }
3799
4128
  apply()
3800
- let observer = null
3801
4129
  if (typeof window.ResizeObserver === 'function') {
3802
4130
  observer = new window.ResizeObserver(schedule)
3803
4131
  for (const target of all()) {
@@ -3805,12 +4133,35 @@ window.__ModuleLoader__.load({
3805
4133
  }
3806
4134
  }
3807
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
+ }
3808
4155
  return () => {
3809
4156
  if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', schedule)
3810
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
+ }
3811
4162
  if (frame !== 0 && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame)
3812
4163
  }
3813
- }, [maxWidth, total, mode])
4164
+ }, [maxWidth, total, mode, dockWanted])
3814
4165
 
3815
4166
  if (total === 0) return null
3816
4167
 
@@ -3823,13 +4174,47 @@ window.__ModuleLoader__.load({
3823
4174
  : t('floatDone', { n: total })
3824
4175
  const firstQuestion = visible[0].question
3825
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
+
3826
4182
  const container = children => h('div', {
3827
- className: 'dsa-float',
4183
+ className: docked ? 'dsa-float dsa-dock' : 'dsa-float',
3828
4184
  'data-dsa-root': '',
3829
4185
  ref: containerRef,
3830
- '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),
3831
4197
  }, children)
3832
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
+
3833
4218
  if (mode === 'off') {
3834
4219
  return container(h('button', {
3835
4220
  type: 'button',
@@ -3850,25 +4235,14 @@ window.__ModuleLoader__.load({
3850
4235
  h('span', { className: 'dsa-capsule-chev' }, '›')))
3851
4236
  }
3852
4237
 
3853
- return container(h('div', { className: 'dsa-stack' },
3854
- visible.map(card => h(AnswerCard, {
3855
- key: card.id,
3856
- card,
3857
- surfaceLabel: null,
3858
- onCopy: element => actions.copy(card, element),
3859
- onFollowUp: text => actions.followUp(card, text),
3860
- onRetry: () => actions.retry(card),
3861
- onRetryEdit: text => actions.retryEdit(card, text),
3862
- onCancel: () => actions.cancel(card),
3863
- onSendToMain: () => actions.sendToMain(card),
3864
- onClose: () => actions.close(card),
3865
- onLocate: target => actions.locateCard(target ?? card),
3866
- onExport: target => actions.exportCard(target ?? card),
3867
- onInsert: target => actions.insertAnswer(target ?? card),
3868
- onReplace: target => actions.insertAnswer(target ?? card, { replace: true }),
3869
- onViewVersion: index => actions.viewVersion(card, index),
3870
- onFloatMode: setFloatMode,
3871
- }))))
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))
3872
4246
  }
3873
4247
 
3874
4248
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
@@ -3945,7 +4319,10 @@ window.__ModuleLoader__.load({
3945
4319
  else toast(t('notifyGranted'))
3946
4320
  }
3947
4321
 
3948
- 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 },
3949
4326
  h('label', null, label),
3950
4327
  control,
3951
4328
  hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
@@ -4176,6 +4553,8 @@ window.__ModuleLoader__.load({
4176
4553
  ? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
4177
4554
  : null)
4178
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.
4179
4558
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
4180
4559
  h(Styles, null),
4181
4560
  h('div', null,
@@ -4188,15 +4567,15 @@ window.__ModuleLoader__.load({
4188
4567
  ['shortcut', t('setTriggerShortcut')],
4189
4568
  ['both', t('setTriggerBoth')],
4190
4569
  ])),
4191
- field(t('setCarrier'), select('defaultCarrier', [
4192
- ['side', t('carrierSide')],
4193
- ['main', t('carrierMain')],
4194
- ])),
4570
+ field(t('setCarrier'), h(CarrierSeg, {
4571
+ value: draft.defaultCarrier,
4572
+ onChange: id => setDraft({ ...draft, defaultCarrier: id }),
4573
+ }), undefined, { gridColumn: 'span 2' }),
4195
4574
  field(t('setSurface'), select('sideSurface', [
4196
4575
  ['auto', t('setSurfaceAuto')],
4197
- ['flow', t('setSurfaceFlow')],
4198
- ['native-rightbar', t('surfaceNative')],
4199
4576
  ['better-sidebar', t('surfaceBetter')],
4577
+ ['flow', t('setSurfaceFlow')],
4578
+ ['native-rightbar', t('surfaceNativeForce')],
4200
4579
  ])),
4201
4580
  field(t('setZones'), select('captureZones', [
4202
4581
  ['auto', t('setZonesAuto')],
@@ -4276,6 +4655,14 @@ window.__ModuleLoader__.load({
4276
4655
  }),
4277
4656
  t('setFollowUps')),
4278
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')),
4279
4666
  historySection,
4280
4667
  h('div', { className: 'dsa-row' },
4281
4668
  h('button', {
@@ -5703,7 +6090,7 @@ window.__ModuleLoader__.load({
5703
6090
  }
5704
6091
  }, [cardId])
5705
6092
  if (card === undefined) {
5706
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
6093
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
5707
6094
  h(Styles, null),
5708
6095
  h('div', { className: 'dsa-muted' }, t('cardClosed')))
5709
6096
  }
@@ -5714,11 +6101,11 @@ window.__ModuleLoader__.load({
5714
6101
  // note for a rail that keeps it.
5715
6102
  try { surfaces.native.close(card) } catch { /* the rail owns its lifecycle */ }
5716
6103
  try { surfaces.better.close(card) } catch { /* the host may already be gone */ }
5717
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
6104
+ return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
5718
6105
  h(Styles, null),
5719
6106
  h('div', { className: 'dsa-muted' }, t('cardMovedToFlow')))
5720
6107
  }
5721
- 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' } },
5722
6109
  h(Styles, null),
5723
6110
  h(AnswerCard, {
5724
6111
  card,
@@ -5896,6 +6283,7 @@ window.__ModuleLoader__.load({
5896
6283
  renderRichText,
5897
6284
  pickSurface,
5898
6285
  floatPlacement,
6286
+ railDock,
5899
6287
  compareVersions,
5900
6288
  electFamilyLeader,
5901
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.1'
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.1",
3
+ "version": "1.8.3",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",