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:
+1
-1
@@ -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
@@ -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。
|
||||||
- [ ] 通道项目在项目选择器的“远程通道”分组中显示。
|
- [ ] 通道项目在项目选择器的“远程通道”分组中显示。
|
||||||
|
- [ ] 项目选择器使用富信息单选菜单;每项显示项目名称和类型,本地项目显示目录摘要,远程通道显示平台来源与默认目录,并在可复用状态数据接入后显示文字连接状态。
|
||||||
|
- [ ] 项目选择器的信息结构可扩展为“本地 / 远程”分组,不以图标或颜色作为项目来源和状态的唯一表达。
|
||||||
- [ ] 停用或断开通道不会删除项目和历史。
|
- [ ] 停用或断开通道不会删除项目和历史。
|
||||||
- [ ] 普通项目删除流程不能永久删除通道项目。
|
- [ ] 普通项目删除流程不能永久删除通道项目。
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user