Composer 选项菜单与 Tool Profile 二级菜单

范围:ControlsCluster 中的选项(+)菜单,以及其 Tools 行右侧齿轮打开的二级菜单。本文是两者的设计与验收清单。

选项菜单就是全应用通用的那套 radix 下拉——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 APItop 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)不变。

验证

项目方式
菜单文法与二级菜单行为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;不以第二实例作为验收依据