diff --git a/FEATURES.md b/FEATURES.md index 6344314..64b113c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,7 +12,7 @@ ### 桌面基础、工作空间与上下文 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 -- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。 +- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 1b7d616..d610f66 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -324,16 +324,19 @@ - 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。 - 使用全局菜单时,菜单项使用 `--font-body`、`14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 -### 6.11 上下文单选菜单 +### 6.11 单选选择器 模型、专家角色、工作模式、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。 +- 只有选项能由单行短标签充分区分、不需要补充来源、位置、状态或说明时,才使用原生 `select`。 +- 当用户必须在选择前比较来源、范围、位置、连接状态或其他辅助信息时,使用与输入区选择器同类的自定义富信息单选菜单,不用 CSS 修饰原生 `select` 冒充富信息菜单。 - 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。 - 菜单使用 `menu` 与 `menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。 - 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。 - 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。 - 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。 -- 仅在选项简单且不需要说明、禁用原因或一致菜单行为时使用原生 `select`。 +- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称和项目类型,并按数据可用性补充本地目录、远程来源、平台、连接状态或其他能帮助辨认项目的信息。 +- 项目菜单当前区分“本地项目 / 远程通道”。未来出现真正的远程项目时新增独立分组,不把消息通道命名为远程项目。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。 ### 6.12 应用通知与就地反馈 diff --git a/docs/prd/channels/wechat-clawbot-channel-project-prd.md b/docs/prd/channels/wechat-clawbot-channel-project-prd.md index 6fcebc4..2e1c43c 100644 --- a/docs/prd/channels/wechat-clawbot-channel-project-prd.md +++ b/docs/prd/channels/wechat-clawbot-channel-project-prd.md @@ -79,10 +79,10 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 ### 5.1 项目分组 -项目选择器增加“远程通道”分组: +项目选择器使用富信息单选菜单,不使用只显示项目名称的原生 `select`。当前区分“本地项目 / 远程通道”;未来出现真正的远程项目时,应新增独立分组,不把通道提前命名为远程项目: ```text -普通项目 +本地项目 ├─ 默认项目 └─ 用户创建的其他项目 @@ -92,6 +92,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 └─ 钉钉 ``` +选择器收起时显示当前项目名称;展开后每个选项至少显示项目名称和项目类型。本地项目补充目录摘要,远程通道项目补充平台来源和默认目录;接入可复用的通道状态数据后继续补充文字连接状态。未来接入其他远程项目时可以补充远程主机、工作区或服务来源,而不改变菜单的基本结构。 + 每个通道项目持续显示连接状态: - 未配置 @@ -645,6 +647,8 @@ Renderer 快照只返回是否已配置和脱敏标识。 - [ ] 同名普通项目不会被占用或修改。 - [ ] 通道项目默认根目录为当前用户目录,默认模式为 Ask。 - [ ] 通道项目在项目选择器的“远程通道”分组中显示。 +- [ ] 项目选择器使用富信息单选菜单;每项显示项目名称和类型,本地项目显示目录摘要,远程通道显示平台来源与默认目录,并在可复用状态数据接入后显示文字连接状态。 +- [ ] 项目选择器的信息结构可扩展为“本地 / 远程”分组,不以图标或颜色作为项目来源和状态的唯一表达。 - [ ] 停用或断开通道不会删除项目和历史。 - [ ] 普通项目删除流程不能永久删除通道项目。 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index ace9a63..6f0f8b5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4706,6 +4706,52 @@ describe('App', () => { ) }) + it('shows grouped project details and keeps the rich menu keyboard accessible', async () => { + const channelProject = { + ...project, + id: '00000000-0000-4000-8000-000000000201', + name: '微信 ClawBot', + kind: 'channel' as const, + channel: 'weixin' as const + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + channelProject + ]) + render() + + const trigger = await screen.findByRole('button', { + name: '当前项目' + }) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + + const menu = screen.getByRole('menu', { name: '当前项目' }) + expect( + within(menu).getByRole('group', { name: '本地项目' }) + ).toHaveTextContent('本地目录 · C:\\Users\\test') + expect( + within(menu).getByRole('group', { name: '远程通道' }) + ).toHaveTextContent( + '微信 ClawBot · 远程通道 · C:\\Users\\test' + ) + const selectedProject = within(menu) + .getByText(project.name, { selector: 'b' }) + .closest('[role="menuitemradio"]') + const remoteProject = within(menu) + .getByText(channelProject.name, { selector: 'b' }) + .closest('[role="menuitemradio"]') + expect(selectedProject).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(selectedProject).toHaveFocus()) + + fireEvent.keyDown(selectedProject!, { key: 'End' }) + expect(remoteProject).toHaveFocus() + fireEvent.keyDown(remoteProject!, { key: 'Escape' }) + expect(trigger).toHaveFocus() + expect( + screen.queryByRole('menu', { name: '当前项目' }) + ).not.toBeInTheDocument() + }) + it('keeps channel projects empty until a client message creates a remote conversation', async () => { const channelProject = { ...project, diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index 46dc684..8d50c14 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -1,7 +1,11 @@ import { Archive, + Check, + ChevronDown, + Folder, FolderOpen, Plus, + RadioTower, Settings, Trash2, X @@ -15,7 +19,8 @@ import type { } from '../../shared/assistant-contracts' import { interactiveWorkModes, - normalizeInteractiveWorkMode + normalizeInteractiveWorkMode, + projectChannelLabels } from '../../shared/assistant-contracts' import type { RuntimeSettings } from '../../shared/contracts' import { trapTabFocus } from './dialog-focus' @@ -66,10 +71,14 @@ export function ProjectSwitcher({ const [error, setError] = useState() const createButtonRef = useRef(null) const settingsButtonRef = useRef(null) + const projectPickerRef = useRef(null) + const projectPickerButtonRef = useRef(null) + const projectPickerMenuRef = useRef(null) const dialogRef = useRef(null) const restoreFocusTarget = useRef< 'create' | 'settings' | undefined >(undefined) + const [projectMenuOpen, setProjectMenuOpen] = useState(false) const [draft, setDraft] = useState({ name: '', description: '', @@ -87,6 +96,38 @@ export function ProjectSwitcher({ ) const busy = saving || archiving || deleting + useEffect(() => { + if (!projectMenuOpen) { + return + } + const focusFrame = requestAnimationFrame(() => { + const items = Array.from( + projectPickerMenuRef.current?.querySelectorAll( + '[role="menuitemradio"]' + ) ?? [] + ) + const initialItem = + items.find((item) => item.getAttribute('aria-checked') === 'true') ?? + items[0] + initialItem?.focus() + }) + const closeOutside = (event: Event): void => { + if ( + event.target instanceof Node && + !projectPickerRef.current?.contains(event.target) + ) { + setProjectMenuOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + document.addEventListener('focusin', closeOutside) + return () => { + cancelAnimationFrame(focusFrame) + document.removeEventListener('pointerdown', closeOutside) + document.removeEventListener('focusin', closeOutside) + } + }, [projectMenuOpen]) + useEffect(() => { if (!dialogMode) { if (restoreFocusTarget.current === 'create') { @@ -207,36 +248,150 @@ export function ProjectSwitcher({ return (
- +