Composer 选项菜单与 Tool Profile 二级菜单
范围:ControlsCluster 中的选项(+)菜单,以及其 Tools 行右侧齿轮打开的二级菜单。本文是两者的设计与验收清单。
components/ui/dropdown-menu.tsx 的 DropdownMenu / DropdownMenuContent / DropdownMenuItem / DropdownMenuSub——铺在 components/chat/top-bar/menu-styles.ts 的共享菜单 token 上:玻璃材质的 MENU_PANEL 画框(10px 圆角、6px 衬)、itemCls 行(24px 高、13px 字、6px 圆角、hover 是唯一底色)、GROUP_LABEL、通铺的 MENU_SEPARATOR,以及右对齐的勾列(CHECK_SLOT / CHECK_SLOT_PAD)。它没有自己的 module CSS。Tool Profile 二级菜单是 radix DropdownMenuSub,打开状态由 React 控制:齿轮只响应点击,鼠标移出不关闭;点击外部、Escape、选择 profile 或关闭一级菜单时关闭。行
每一行都是 PlusMenuRow(controls/menu-pieces.tsx):一个穿着 itemCls(false) 加 MENU_ITEM_STATES 的 DropdownMenuItem,后者把 radix 的键盘高亮、已打开的子菜单触发器和禁用属性映射到同一条 hover 规则上。布局就是共享文法——16px Solar 图标列、标签、可选的右侧元数据、再是勾列。选中行显示 14px 墨色 check-circle,在变为选中的那一刻弹入一次;其余行用 CHECK_SLOT_PAD 占住这一列,所以标签和勾与顶栏菜单落在同一竖列。开关类行(Tools、Web Search、Sandbox、无人值守)通过阻止 radix 的 select 保持菜单打开;添加文件和 profile 行让菜单关闭。Sandbox 不可用时是禁用行,保留原生 title 说明原因。
参考实现比较
| 方案 | 能力 | 结论 |
|---|---|---|
| radix 下拉 + 子菜单,铺在共享菜单 token 上 | 受控 open、portal、带 flip 与碰撞处理的浮动定位、roving focus、typeahead、Escape、outside press;与顶栏、Recents、标签页菜单同一套包装与 token | 采用;全应用一套菜单文法 |
Base UI Menu + CSS module 样式 | 行为相同,但多一个菜单库,面板与行样式是第二份手写拷贝,已与共享样式漂移 | 已移除;选项菜单是唯一使用者 |
| 手写 fixed + backdrop | 点击状态可实现;缺少 portal 层级、自动 flip、完整键盘与 focus 行为 | 拒绝;重复已有基础设施 |
| 原生 Popover API | top layer 与 light dismiss;不提供菜单的 roving focus、子菜单关系和定位合同 | 不采用;会同时维护两套菜单语义 |
目标状态与边界
状态
profileMenuOpen是唯一的二级 open 状态。- 一级菜单关闭时清除它。
- profile 选择沿用
switchProfile。
DOM 与层级
- 两级菜单都通过 radix portal 挂到 body。
- 一级内容
z-[200],二级z-[201];radix 会把内容的 z-index 复制到它的 popper 包装上。 - radix 负责 anchor、flip 和碰撞处理;一级以
side="top" align="start" sideOffset={9}打开,二级align="end" sideOffset={6}。
明确排除
- 不修改 Tools 行开关语义。
- 不增加依赖或全局 overlay 管理器。
- 不改 profile 数据、后端或其他弹层。
信任边界、隐私、并发和取消均不涉及:只是本地展示,不读写新的持久数据,不发起网络请求。
Tools 分裂行
Tools 视觉行是一个 role="none" 容器,内部放两个同级、非嵌套的 menuitem:Tools PlusMenuRow 和齿轮 DropdownMenuSubTrigger。鼠标和键盘都能分别激活两者;激活 Tools 切换开关并关闭 profile 菜单,激活齿轮只切换 profile 菜单。布局顺序是图标 · 标签 · 齿轮 · 勾列:齿轮是 22×22px 按钮、14px settings-minimalistic 图标,绝对定位盖在行里紧贴勾列之前预留的 22px 槽位上——10px 行内衬 + 14px 勾 + 8px 间距 = 距右缘 32px。因为每一行都预留勾列,Tools 开关切换时齿轮不会移动。悬停或聚焦齿轮会像单行一样把整行染上 hover 底色(容器是 Tailwind group),齿轮自己在悬停、键盘高亮和子菜单打开时再叠一层淡墨。
radix 默认在指针停留时打开子菜单、焦点移到同级行时关闭它;本组件为这个只点击的齿轮覆盖了两处:触发器上的 onPointerMove 阻止悬停打开,子菜单内容上的 onFocusOutside 阻止焦点驱动的关闭,onPointerDownOutside 则在任何不是齿轮的按下时关闭子菜单。其余——一级菜单的 outside press、Escape、选择 profile、父菜单关闭、键盘打开与关闭——仍由 radix 处理。
验收
生产文件:apps/web/components/chat/composer/controls/controls-cluster.tsx、controls/menu-pieces.tsx、components/chat/top-bar/menu-styles.ts。回归入口:npm run check:composer-tool-profile。兼容要求:Tools 行仍切换 tools;profile 行仍调用现有回调;其他行的行为(添加文件、保持菜单打开的开关、禁用的 Sandbox)不变。
- 选项面板、行、标题、分隔线和勾全部来自
menu-styles.ts;composer.module.css中没有.plusMenu*规则。 - 点击齿轮打开二级菜单;鼠标移到其他行上不会关闭;再点一次齿轮关闭。
- 二级面板绘制在一级面板之上,不受 composer 层叠上下文限制。
- 点击外部、按 Escape、选择 profile、关闭一级菜单均关闭二级菜单。
- Tools 行顺序是
标签 · 齿轮 · 勾,不是标签 · 勾 · 齿轮,且 Tools 切换时齿轮不移动。 - 不存在
menuPosition、profileMenuBackdrop或手写 fixed 坐标。
验证
| 项目 | 方式 |
|---|---|
| 菜单文法与二级菜单行为 | npm run check:composer-tool-profile:radix 包装、共享 token、无 module CSS、受控二级菜单、只点击打开、指针移动不关闭、同级顺序、齿轮几何、层级、父菜单关闭 |
| 类型检查 | 在 apps/web 运行 npx tsc --noEmit -p . |
| 完整前端检查 | 在 apps/web 运行 npm run check 与 npm run test:unit |
| 可见验收 | /Applications/OpenProgram.app、默认 profile 和端口 18100;不以第二实例作为验收依据 |