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
+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 () => {
const channelProject = {
...project,
+180 -24
View File
@@ -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<string>()
const createButtonRef = 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 restoreFocusTarget = useRef<
'create' | 'settings' | undefined
>(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [draft, setDraft] = useState<ProjectCreateInput>({
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<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(() => {
if (!dialogMode) {
if (restoreFocusTarget.current === 'create') {
@@ -207,36 +248,150 @@ export function ProjectSwitcher({
return (
<div className="project-switcher">
<div className="project-switcher__row">
<select
aria-label={t('projectSwitcher.selector.ariaLabel')}
onChange={(event) => onSelect(event.target.value)}
value={activeProjectId}
<div
className="project-switcher__picker"
ref={projectPickerRef}
>
{userProjects.length > 0 && (
<optgroup label={t('projectSwitcher.selector.userProjects')}>
{userProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</optgroup>
)}
{channelProjects.length > 0 && (
<optgroup
label={t('projectSwitcher.selector.channelProjects')}
<button
aria-expanded={projectMenuOpen}
aria-haspopup="menu"
aria-label={t('projectSwitcher.selector.ariaLabel')}
className="project-switcher__trigger"
onClick={() => setProjectMenuOpen((open) => !open)}
onKeyDown={(event) => {
if (
!projectMenuOpen &&
(event.key === 'ArrowDown' ||
event.key === 'Enter' ||
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}
</option>
))}
</optgroup>
{[
{
projects: userProjects,
label: t('projectSwitcher.selector.userProjects'),
icon: Folder
},
{
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
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
onClick={() => {
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
@@ -265,6 +420,7 @@ export function ProjectSwitcher({
if (!activeProject) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
@@ -150,6 +150,15 @@ describe('WorkspacePrimitives', () => {
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', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -9,8 +9,12 @@ export const workspace = {
},
selector: {
ariaLabel: 'Current project',
userProjects: 'Projects',
userProjects: 'Local projects',
channelProjects: 'Remote channels',
empty: 'No project selected',
localDetail: 'Local folder · {{path}}',
remoteDetail: '{{channel}} · Remote channel · {{path}}',
remoteChannel: 'Remote channel',
create: 'New project',
settings: 'Project settings'
},
@@ -6,8 +6,12 @@ export const workspace = {
},
selector: {
ariaLabel: '当前项目',
userProjects: '普通项目',
userProjects: '本地项目',
channelProjects: '远程通道',
empty: '未选择项目',
localDetail: '本地目录 · {{path}}',
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
remoteChannel: '远程通道',
create: '新建项目',
settings: '项目设置'
},
+108 -3
View File
@@ -2317,15 +2317,120 @@ textarea:focus-visible {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.project-switcher select {
width: 100%;
.project-switcher__picker {
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-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
cursor: pointer;
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 {