Composer Tool Profile 菜单设计
范围:ControlsCluster 中 Tools 行右侧齿轮打开的二级菜单。本文同时作为单任务设计、实施 brief 和证据 ledger。
Menu.SubmenuRoot、SubmenuTrigger、Portal 和 Positioner。打开状态受 React 控制;齿轮只响应点击,鼠标移出不关闭;点击外部、Escape、选择 profile 或关闭一级菜单时关闭。当前实现与已验证缺口
一级菜单通过 Menu.Portal 挂载到 document body,positioner 使用 z-index: 200。失败实现把二级菜单作为 composer 内的 position: fixed 节点,并增加透明 backdrop。composer 的 .inputArea 自身是 z-index: 5 的层叠上下文,因此二级节点即使设置 z-index: 9999,仍位于 body portal 的一级菜单之后。截图中的一级菜单覆盖二级菜单由此产生。
参考实现比较
| 方案 | 能力 | 结论 |
|---|---|---|
| Base UI submenu | 受控 open、click trigger、outside press、Escape、focus、portal、floating position | 采用;项目已安装且原代码已使用 |
| 手写 fixed + backdrop | 点击状态可实现;缺少 portal 层级、自动 flip、完整键盘与 focus 行为 | 拒绝;重复已有基础设施并产生当前缺陷 |
| 原生 Popover API | top layer 与 light dismiss;不提供现有 Menu 的 roving focus、submenu 关系和定位合同 | 本次不采用;会同时维护两套菜单语义 |
目标状态与边界
状态
profileMenuOpen是唯一二级 open 状态。- 一级关闭时显式清除二级状态。
- profile 选择沿用
switchProfile。
DOM 与层级
- 两级菜单均由 Base UI portal 挂到 body。
- 一级 positioner 为 200,二级为 201。
- Base UI 负责 anchor、flip 和碰撞处理。
明确排除
- 不修改 Tools 行开关语义。
- 不增加依赖或全局 overlay 管理器。
- 不改 profile 数据、后端或其他弹层。
信任边界、隐私、并发和取消均不涉及:本改动只控制本地展示状态,不读写新的持久数据,不发起网络请求。
Tools 视觉行使用一个 role="none" 容器,内部放置两个同级且非嵌套的 menuitem:Tools Menu.Item 和齿轮 SubmenuTrigger。鼠标和键盘都能分别激活两个动作;激活 Tools 会切换开关并关闭 profile,激活齿轮只切换 profile。视觉必须保持原布局:Tools 标签在左,右侧先显示齿轮,启用时再在最右侧显示勾;齿轮的 22px 尺寸、14px 图标及其与勾的间距不变。Base UI 会用 sibling-open 原因报告父菜单 item 的 pointer move,本组件只忽略这一种关闭原因;outside press、Escape、item press 和父菜单关闭等其他原因仍由 Base UI 处理。
实施 brief 与验收
生产文件:web/components/chat/composer/controls/controls-cluster.tsx、composer.module.css。回归入口:npm run check:composer-tool-profile。兼容要求:Tools 行仍切换 tools;profile 项仍调用现有回调;一级菜单其他项不变。
- 点击齿轮打开二级菜单,移动鼠标到菜单外不会关闭。
- 二级面板全部绘制在一级面板之上,不受 composer 层叠上下文限制。
- 点击外部、按 Escape、选择 profile、关闭一级菜单均关闭二级菜单。
- Tools 行保持原视觉顺序:
标签 · 齿轮 · 勾,不得改成标签 · 勾 · 齿轮。 - 不再存在
menuPosition、profileMenuBackdrop或手写 fixed 坐标。
当前实现与验证证据
| 项目 | 证据 | 状态 |
|---|---|---|
| 菜单行为 | npm run check:composer-tool-profile 覆盖受控二级菜单、父菜单关闭、层级、键盘和 store 归属 | 通过 |
| 类型检查 | npx tsc --noEmit | 通过 |
| 实现约束 | sibling-open 是唯一被忽略的父菜单关闭原因;outside press、Escape、item press 与父菜单关闭保持有效 | 符合 |
| 安装版入口 | 可见验收使用 /Applications/OpenProgram.app、默认 profile 和端口 18100;本页不以第二实例作为验收依据 | 固定 |
| 完整前端检查 | cd web && npm run check,包含新增菜单检查 | 通过 |
| 生产构建 | cd web && npm run build,29 个静态页面生成 | 通过 |
| 文档 | python -m tools.docs_site.checklinks:0 broken links | 通过 |
| 视觉约束 | gear 位于 check 左侧,按钮为 22×22px、图标为 14px,并保留既有间距与整行 hover | 符合 |