# 专家选择器:两次布局问题排查记录
> 对象:dsh-workbuddy-experts 客户端 `ExpertSelect`(注入 dsh Web 的 composer)
> 日期:2026-08-20
> 结论:均已修复并重建通过(代码在 `src/client/ExpertSelect.tsx` / `ExpertSelect.module.css` / `index.tsx`)。
> 经验:**在 dsh 的 composer slot 里放自定义下拉,定位要锚定触发器本身(portal+fixed),而不是依赖外层容器的绝对定位;选槽要看槽的语义位置,不是"看着像"就得了;portal 浮层的"外点关闭"必须把浮层自身也列入放行区,否则会吞掉选项点击。**
---
## 布局错误 1:下拉菜单被定位到屏幕右侧,远离左下角的触发器
### 现象
触发器(专家选择按钮)在发送框左下角,但点开后的下拉菜单飞到屏幕**最右侧**,与触发器完全脱开。
### 根因
- 插件最初把控件注入 `conversation.input.dock`(发送框**上方**的全宽 dock 条,哥尔巴/goal bar 所在位置)。
- 菜单用的是 `position: absolute; right: 0; bottom: calc(100% + 8px)`,定位容器是 `.root`。
- 但在这个**通栏 dock 条**里,`.root` 被 flex 拉伸为**整条宽**,于是 `right: 0` 锚到的是**整条 strip 的右缘**,而触发器在左缘 → 菜单被甩到最右。
本质:**`position: absolute` 的定位容器(containing block)不是窄的触发器,而是被拉伸的全宽父级。** 任何依赖"相对某元素定位"的写法,一旦父级变宽就会错位。
### 修复
把菜单改为 **portal 渲染到 `document.body` + `position: fixed`**,坐标每次打开时用 `triggerRef.getBoundingClientRect()` 计算,并随 `resize`/`scroll` 刷新:
```
菜单右缘 = 触发器右缘;菜单底部 = 触发器顶部上方 8px(向上弹出)
右缘/最大尺寸用 min(..., calc(100vw/100vh)) 钳制在视口内,避免过高/过宽时出画布
```
配套把 `.root` 设为 `display: inline-flex; flex: none`,避免再被通栏拉伸。
> 这是 dsh UI 原语(模型选择、哥尔巴、菜单组件)的通用做法:**浮层用 portal + 由触发器 rect 计算坐标**,从而不依赖外层容器的定位/overflow 上下文。
### 关键代码
`src/client/ExpertSelect.tsx`:
- `createPortal(
{...}
, document.body)`
- `menuStyle`:`position:'fixed'` + `right`/`bottom` 由 `anchor`(触发器 rect)计算
- `useLayoutEffect` 在 `open` 时捕获 rect,并监听 `resize`/`scroll`
`src/client/ExpertSelect.module.css`:
- `.root { display:inline-flex; flex:none }`(不再拉伸)
- 删除 `.menu` 里的 `position:absolute; right:0; bottom:...`(定位改由内联 fixed style)
---
## 布局错误 2:专家选择框不在输入框(input)内部
### 现象
菜单位置对了之后,发现控件本体**不在输入框内部**——它还挂在发送框**上方的全宽 dock 条**里,而不是跟随输入框。
### 根因
选错了 slot。`conversation.input.dock` 是**发送框上方的全宽 dock 条**(哥尔巴条所在的位置),语义上不属于输入框内部;而模型选择器用的是 `conversation.input.model`(输入卡内部的模型座)。所以控件被渲染在输入框外面。
### 修复
改用 **`conversation.input.right`**:输入卡**内侧右侧**的 dock,紧挨模型座,属于 input 内部。
`src/client/index.tsx`:
```
export const EXPERT_SLOT = 'conversation.input.right'
```
保留 list 型槽的注册规范(`id` + `order`,与模型座/哥尔巴一致)。
### dsh 可用的 input 槽枚举(供后续选择)
从 ui-conversation bundle 提取的 `conversation.input.*` 槽:
`attachments` · `dock` · `for` · `left` · `model` · `overlay` · `plan` · `right`
- **模型选择器**:`conversation.input.model`(input 内部模型座)
- **专家选择器(本插件)**:`conversation.input.right`(input 内侧右侧)
- **哥尔巴条**:`conversation.input.dock`(发送框上方全宽条)
- **输入触发器浮层**:`conversation.input.overlay`
---
## 布局/交互错误 3:portal 菜单外点关闭吞点击 → 选中后无法切换到其它专家
### 现象
第一个专家能选上,但**选中后再打开菜单,点其它专家切换无效**(菜单被关了,但选择没变)。
### 根因
菜单改 portal 到 `document.body` 后,它**不再是触发器 DOM 的子树**。而"外点关闭"监听只判断 `triggerRef` 是否包含点击目标:
```js
const onPointer = (event) => {
if (triggerRef.current?.contains(event.target)) return
setOpen(false) // 菜单里的选项也不在 trigger 内 → mousedown 直接关闭菜单
}
```
在菜单选项上按 `mousedown` → 判定为"在外部" → `setOpen(false)` 卸载菜单 → 随后该选项的 `onClick`(切换逻辑)常常不再触发。第一次能选上纯属竞态碰巧,后续切换必然失败。
本质:**"外点关闭"的判定区漏掉了浮层本体。** 一旦浮层是 portal(不在触发器子树里),把浮层内容当作"外部点击"去关闭,就会在 `mousedown` 阶段提前卸载,吞掉目标元素的 `click`。
### 修复
- 给菜单元素加 `menuRef`,外点关闭时把 `inTrigger || inMenu` 都放行(忽略),不让 `mousedown` 在选项上关闭菜单 → 选项 `onClick` 可靠触发。
- 顺带加固 `choose`:host 对未知专家返回 `HTTP 200 + {ok:false}`,原本会被当成成功而错误更新显示;现在校验 `result.ok`,失败仅提示、不改当前选择。
```js
// 外点关闭 —— 触发器与菜单都要放行
const inTrigger = triggerRef.current?.contains(target)
const inMenu = menuRef.current?.contains(target)
if (inTrigger || inMenu) return
setOpen(false)
```
### 通用规则补充
- **portal 浮层 + 外点关闭**:判定区 = 触发器 ∪ 浮层自身(两个 ref),二者都不属"外部"。
- 关闭浮层的触发点:选项的 `onClick` 里自己 `setOpen(false)`,不要在 `mousedown` 阶段由外部监听抢先卸载。
- 后端"业务失败"不要只看 HTTP 状态码:约定错误码/字段(如 `{ok:false}`)也要校验,否则 UI 会显示未真正生效的选择。
---
## 启发 / 通用规则(写给自己)
1. **自定义下拉在 dsh composer 里的稳妥姿势**:触发器留在原位,浮层用 portal + `position: fixed` + 触发器 `getBoundingClientRect()` 定位;配套 `.root` 不龟入 flex 拉伸。
2. **选 slot 先看语义位置,再看是不是 list 型**:`conversation.input.dock` ≠ `conversation.input.right`,一个是条、一个是 input 内侧;list 型槽注册必须带 `id`(不能只带 `key`)。
3. **验证要分层**:纯逻辑 `scripts/verify.mjs`;运行时 UI 观察(下拉位置、是否在 input 内、是否被裁剪/带跑)。
4. **portal 浮层 + 外点关闭**:判定区 = 触发器 ∪ 浮层自身(两个 ref);浮层本体不属于"外部",否则 `mousedown` 会抢先卸载浮层、吞掉选项 `click`(见第 3 条)。