feat: enrich project selector details

The project selector previously used a native list that showed only project names and inherited platform-specific styling. It now presents an accessible rich menu grouped into local projects and remote channels, with folder or channel context for each option.

Release note: 项目选择器现在按本地项目和远程通道分组,并显示目录或通道来源,切换项目时更容易辨认目标。
This commit is contained in:
mesalogo
2026-08-19 22:19:47 +08:00
parent 34033053be
commit 20c15f74c6
9 changed files with 365 additions and 34 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
### 桌面基础、工作空间与上下文 ### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。
- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。 - [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
+5 -2
View File
@@ -324,16 +324,19 @@
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。 - 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
- 使用全局菜单时,菜单项使用 `--font-body``14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu``menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 - 使用全局菜单时,菜单项使用 `--font-body``14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu``menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。
### 6.11 上下文单选菜 ### 6.11 单选选择器
模型、专家角色、工作模式、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。 模型、专家角色、工作模式、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。
- 只有选项能由单行短标签充分区分、不需要补充来源、位置、状态或说明时,才使用原生 `select`
- 当用户必须在选择前比较来源、范围、位置、连接状态或其他辅助信息时,使用与输入区选择器同类的自定义富信息单选菜单,不用 CSS 修饰原生 `select` 冒充富信息菜单。
- 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。 - 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。
- 菜单使用 `menu``menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。 - 菜单使用 `menu``menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。
- 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。 - 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。 - 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。 - 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
- 仅在选项简单且不需要说明、禁用原因或一致菜单行为时使用原生 `select` - 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称和项目类型,并按数据可用性补充本地目录、远程来源、平台、连接状态或其他能帮助辨认项目的信息
- 项目菜单当前区分“本地项目 / 远程通道”。未来出现真正的远程项目时新增独立分组,不把消息通道命名为远程项目。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`
### 6.12 应用通知与就地反馈 ### 6.12 应用通知与就地反馈
@@ -79,10 +79,10 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
### 5.1 项目分组 ### 5.1 项目分组
项目选择器增加“远程通道”分组 项目选择器使用富信息单选菜单,不使用只显示项目名称的原生 `select`。当前区分“本地项目 / 远程通道”;未来出现真正的远程项目时,应新增独立分组,不把通道提前命名为远程项目
```text ```text
普通项目 本地项目
├─ 默认项目 ├─ 默认项目
└─ 用户创建的其他项目 └─ 用户创建的其他项目
@@ -92,6 +92,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
└─ 钉钉 └─ 钉钉
``` ```
选择器收起时显示当前项目名称;展开后每个选项至少显示项目名称和项目类型。本地项目补充目录摘要,远程通道项目补充平台来源和默认目录;接入可复用的通道状态数据后继续补充文字连接状态。未来接入其他远程项目时可以补充远程主机、工作区或服务来源,而不改变菜单的基本结构。
每个通道项目持续显示连接状态: 每个通道项目持续显示连接状态:
- 未配置 - 未配置
@@ -645,6 +647,8 @@ Renderer 快照只返回是否已配置和脱敏标识。
- [ ] 同名普通项目不会被占用或修改。 - [ ] 同名普通项目不会被占用或修改。
- [ ] 通道项目默认根目录为当前用户目录,默认模式为 Ask。 - [ ] 通道项目默认根目录为当前用户目录,默认模式为 Ask。
- [ ] 通道项目在项目选择器的“远程通道”分组中显示。 - [ ] 通道项目在项目选择器的“远程通道”分组中显示。
- [ ] 项目选择器使用富信息单选菜单;每项显示项目名称和类型,本地项目显示目录摘要,远程通道显示平台来源与默认目录,并在可复用状态数据接入后显示文字连接状态。
- [ ] 项目选择器的信息结构可扩展为“本地 / 远程”分组,不以图标或颜色作为项目来源和状态的唯一表达。
- [ ] 停用或断开通道不会删除项目和历史。 - [ ] 停用或断开通道不会删除项目和历史。
- [ ] 普通项目删除流程不能永久删除通道项目。 - [ ] 普通项目删除流程不能永久删除通道项目。
+46
View File
@@ -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(<App />)
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<HTMLButtonElement>('[role="menuitemradio"]')
const remoteProject = within(menu)
.getByText(channelProject.name, { selector: 'b' })
.closest<HTMLButtonElement>('[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 () => { it('keeps channel projects empty until a client message creates a remote conversation', async () => {
const channelProject = { const channelProject = {
...project, ...project,
+180 -24
View File
@@ -1,7 +1,11 @@
import { import {
Archive, Archive,
Check,
ChevronDown,
Folder,
FolderOpen, FolderOpen,
Plus, Plus,
RadioTower,
Settings, Settings,
Trash2, Trash2,
X X
@@ -15,7 +19,8 @@ import type {
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import { import {
interactiveWorkModes, interactiveWorkModes,
normalizeInteractiveWorkMode normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts' import type { RuntimeSettings } from '../../shared/contracts'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
@@ -66,10 +71,14 @@ export function ProjectSwitcher({
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const createButtonRef = useRef<HTMLButtonElement>(null) const createButtonRef = useRef<HTMLButtonElement>(null)
const settingsButtonRef = useRef<HTMLButtonElement>(null) const settingsButtonRef = useRef<HTMLButtonElement>(null)
const projectPickerRef = useRef<HTMLDivElement>(null)
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
const restoreFocusTarget = useRef< const restoreFocusTarget = useRef<
'create' | 'settings' | undefined 'create' | 'settings' | undefined
>(undefined) >(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [draft, setDraft] = useState<ProjectCreateInput>({ const [draft, setDraft] = useState<ProjectCreateInput>({
name: '', name: '',
description: '', description: '',
@@ -87,6 +96,38 @@ export function ProjectSwitcher({
) )
const busy = saving || archiving || deleting const busy = saving || archiving || deleting
useEffect(() => {
if (!projectMenuOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
const items = Array.from(
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
'[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(() => { useEffect(() => {
if (!dialogMode) { if (!dialogMode) {
if (restoreFocusTarget.current === 'create') { if (restoreFocusTarget.current === 'create') {
@@ -207,36 +248,150 @@ export function ProjectSwitcher({
return ( return (
<div className="project-switcher"> <div className="project-switcher">
<div className="project-switcher__row"> <div className="project-switcher__row">
<select <div
aria-label={t('projectSwitcher.selector.ariaLabel')} className="project-switcher__picker"
onChange={(event) => onSelect(event.target.value)} ref={projectPickerRef}
value={activeProjectId}
> >
{userProjects.length > 0 && ( <button
<optgroup label={t('projectSwitcher.selector.userProjects')}> aria-expanded={projectMenuOpen}
{userProjects.map((project) => ( aria-haspopup="menu"
<option key={project.id} value={project.id}> aria-label={t('projectSwitcher.selector.ariaLabel')}
{project.name} className="project-switcher__trigger"
</option> onClick={() => setProjectMenuOpen((open) => !open)}
))} onKeyDown={(event) => {
</optgroup> if (
)} !projectMenuOpen &&
{channelProjects.length > 0 && ( (event.key === 'ArrowDown' ||
<optgroup event.key === 'Enter' ||
label={t('projectSwitcher.selector.channelProjects')} event.key === ' ')
) {
event.preventDefault()
setProjectMenuOpen(true)
}
}}
ref={projectPickerButtonRef}
title={activeProject?.name}
type="button"
>
<span>{activeProject?.name ?? t('projectSwitcher.selector.empty')}</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{projectMenuOpen && (
<div
aria-label={t('projectSwitcher.selector.ariaLabel')}
className="project-switcher__menu"
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]'
)
)
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement
)
let nextIndex: number | undefined
if (event.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % items.length
} else if (event.key === 'ArrowUp') {
nextIndex =
(currentIndex - 1 + items.length) % items.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = items.length - 1
} else if (event.key === 'Escape') {
event.preventDefault()
setProjectMenuOpen(false)
projectPickerButtonRef.current?.focus()
}
const nextItem =
nextIndex === undefined ? undefined : items.at(nextIndex)
if (nextItem) {
event.preventDefault()
items.forEach((item) => {
item.tabIndex = item === nextItem ? 0 : -1
})
nextItem.focus()
}
}}
ref={projectPickerMenuRef}
role="menu"
> >
{channelProjects.map((project) => ( {[
<option key={project.id} value={project.id}> {
{project.name} projects: userProjects,
</option> label: t('projectSwitcher.selector.userProjects'),
))} icon: Folder
</optgroup> },
{
projects: channelProjects,
label: t('projectSwitcher.selector.channelProjects'),
icon: RadioTower
}
].map((group) =>
group.projects.length > 0 ? (
<div
aria-label={group.label}
className="project-switcher__group"
key={group.label}
role="group"
>
<strong>{group.label}</strong>
{group.projects.map((project) => {
const selected = project.id === activeProjectId
const ProjectIcon = group.icon
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
channel: project.channel
? projectChannelLabels[project.channel]
: t(
'projectSwitcher.selector.remoteChannel'
),
path: project.rootPath
})
: t('projectSwitcher.selector.localDetail', {
path: project.rootPath
})
return (
<button
aria-checked={selected}
key={project.id}
onClick={() => {
if (!selected) {
onSelect(project.id)
}
setProjectMenuOpen(false)
requestAnimationFrame(() =>
projectPickerButtonRef.current?.focus()
)
}}
role="menuitemradio"
tabIndex={selected ? 0 : -1}
type="button"
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{project.name}</b>
<small>{detail}</small>
</span>
{selected && (
<Check aria-hidden="true" size={14} />
)}
</button>
)
})}
</div>
) : null
)}
</div>
)} )}
</select> </div>
<button <button
aria-label={t('projectSwitcher.selector.create')} aria-label={t('projectSwitcher.selector.create')}
className="icon-button" className="icon-button"
onClick={() => { onClick={() => {
setProjectMenuOpen(false)
setError(undefined) setError(undefined)
setConfirmingDelete(false) setConfirmingDelete(false)
setDeleteConfirmation('') setDeleteConfirmation('')
@@ -265,6 +420,7 @@ export function ProjectSwitcher({
if (!activeProject) { if (!activeProject) {
return return
} }
setProjectMenuOpen(false)
setError(undefined) setError(undefined)
setConfirmingDelete(false) setConfirmingDelete(false)
setDeleteConfirmation('') setDeleteConfirmation('')
@@ -150,6 +150,15 @@ describe('WorkspacePrimitives', () => {
expect(stylesheet).not.toContain('.conversation-delete') expect(stylesheet).not.toContain('.conversation-delete')
}) })
it('uses the shared control and menu surfaces for the rich project picker', () => {
expect(stylesheet).toMatch(
/\.project-switcher__trigger\s*\{[^}]*height:\s*var\(--control-height\);[^}]*border-radius:\s*var\(--radius-control\);[^}]*background:\s*var\(--surface-raised\);/u
)
expect(stylesheet).toMatch(
/\.project-switcher__menu\s*\{[^}]*border-radius:\s*var\(--radius-card\);[^}]*background:\s*var\(--surface-raised\);[^}]*box-shadow:\s*var\(--shadow-dialog\);/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => { it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u /\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -9,8 +9,12 @@ export const workspace = {
}, },
selector: { selector: {
ariaLabel: 'Current project', ariaLabel: 'Current project',
userProjects: 'Projects', userProjects: 'Local projects',
channelProjects: 'Remote channels', channelProjects: 'Remote channels',
empty: 'No project selected',
localDetail: 'Local folder · {{path}}',
remoteDetail: '{{channel}} · Remote channel · {{path}}',
remoteChannel: 'Remote channel',
create: 'New project', create: 'New project',
settings: 'Project settings' settings: 'Project settings'
}, },
@@ -6,8 +6,12 @@ export const workspace = {
}, },
selector: { selector: {
ariaLabel: '当前项目', ariaLabel: '当前项目',
userProjects: '普通项目', userProjects: '本地项目',
channelProjects: '远程通道', channelProjects: '远程通道',
empty: '未选择项目',
localDetail: '本地目录 · {{path}}',
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
remoteChannel: '远程通道',
create: '新建项目', create: '新建项目',
settings: '项目设置' settings: '项目设置'
}, },
+108 -3
View File
@@ -2317,15 +2317,120 @@ textarea:focus-visible {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
} }
.project-switcher select { .project-switcher__picker {
width: 100%;
min-width: 0; min-width: 0;
padding: 8px 9px; }
.project-switcher__trigger {
display: flex;
width: 100%;
height: var(--control-height);
min-width: 0;
align-items: center;
padding: 0 var(--space-3);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
font-size: var(--font-body); font-size: var(--font-body);
font-weight: 600;
gap: var(--space-2);
}
.project-switcher__trigger:hover,
.project-switcher__trigger[aria-expanded="true"] {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
}
.project-switcher__trigger > span {
min-width: 0;
overflow: hidden;
flex: 1;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-switcher__menu {
position: absolute;
z-index: 30;
top: calc(var(--control-height) + var(--space-2));
right: 0;
left: 0;
display: grid;
max-height: min(420px, calc(100vh - 160px));
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow-y: auto;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-2);
}
.project-switcher__group {
display: grid;
gap: var(--space-1);
}
.project-switcher__group + .project-switcher__group {
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.project-switcher__group > strong {
padding: var(--space-1) var(--space-2);
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 650;
}
.project-switcher__group > button {
display: grid;
width: 100%;
min-width: 0;
min-height: 52px;
align-items: center;
padding: var(--space-2);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
text-align: left;
}
.project-switcher__group > button:hover,
.project-switcher__group > button[aria-checked="true"] {
background: var(--accent-subtle);
color: var(--accent);
}
.project-switcher__group > button > span {
display: grid;
min-width: 0;
gap: 2px;
}
.project-switcher__group > button b,
.project-switcher__group > button small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-switcher__group > button b {
font-size: var(--font-body);
font-weight: 650;
}
.project-switcher__group > button small {
color: var(--text-muted);
font-size: var(--font-caption);
} }
.project-create-backdrop { .project-create-backdrop {