fix: unify capability settings navigation

Skills and MCP previously occupied separate top-level settings categories. They now share one Capabilities and tools category with accessible horizontal tabs, while the unimplemented tool environment remains hidden until its settings are usable.

Prevent nested PageTabs wrappers from collapsing to zero height in scrollable Flex layouts, add a CSS regression guard, and document the shared rule plus the planned local Node.js and Python environment.

Release note: 设置中心将 Skills 和 MCP 合并到“能力与工具”,并修复水平页签可能被压缩而不可见的问题;尚未实现的工具执行环境暂不显示。
This commit is contained in:
mesalogo
2026-09-02 00:28:47 +08:00
parent 2fca39ddb2
commit 4f3e0bdcfd
25 changed files with 691 additions and 152 deletions
@@ -0,0 +1,72 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { McpSettingsSection } from './McpSettingsSection'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { PageTabs } from './WorkspacePrimitives'
type CapabilitySettingsTab = 'skills' | 'mcp'
export function CapabilitiesAndToolsSettingsSection({
magicNotesEnabled
}: {
magicNotesEnabled: boolean
}): React.JSX.Element {
const { t } = useTranslation('settings')
const [activeTab, setActiveTab] =
useState<CapabilitySettingsTab>('skills')
const [mcpVisited, setMcpVisited] = useState(false)
return (
<>
<SettingsCategoryHeader
category="capabilities"
headingId="capabilities-settings-heading"
/>
<div className="capabilities-settings__tabs">
<PageTabs
ariaLabel={t('capabilities.tabs.ariaLabel')}
idPrefix="capabilities-settings"
onChange={(tab) => {
setActiveTab(tab)
if (tab === 'mcp') {
setMcpVisited(true)
}
}}
tabs={[
{
id: 'skills',
label: t('capabilities.tabs.skills')
},
{
id: 'mcp',
label: t('capabilities.tabs.mcp')
}
]}
value={activeTab}
variant="segmented"
/>
</div>
<section
aria-labelledby="capabilities-settings-tab-skills"
className="capabilities-settings__panel"
hidden={activeTab !== 'skills'}
id="capabilities-settings-panel-skills"
role="tabpanel"
>
<SkillsSettingsSection />
</section>
<section
aria-labelledby="capabilities-settings-tab-mcp"
className="capabilities-settings__panel"
hidden={activeTab !== 'mcp'}
id="capabilities-settings-panel-mcp"
role="tabpanel"
>
{mcpVisited && (
<McpSettingsSection magicNotesEnabled={magicNotesEnabled} />
)}
</section>
</>
)
}
+5 -3
View File
@@ -32,7 +32,7 @@ import type {
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import {
SettingsCategoryHeader,
SettingsSectionHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { PageTabs } from './WorkspacePrimitives'
@@ -394,7 +394,7 @@ export function McpSettingsSection({
return (
<>
<SettingsCategoryHeader
<SettingsSectionHeader
actions={
activeTab === 'custom' ? (
<button
@@ -410,9 +410,11 @@ export function McpSettingsSection({
</button>
) : undefined
}
category="mcp"
description={t('mcp.description')}
error={!editor ? error : undefined}
headingLevel={3}
headingId="mcp-settings-heading"
title={t('mcp.title')}
/>
<SettingsWarningList warnings={snapshot?.warnings} />
<div className="mcp-settings__tabs">
+86 -40
View File
@@ -53,6 +53,17 @@ import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
function openCapabilitySettingsTab(tab: 'Skills' | 'MCP'): void {
fireEvent.click(
screen.getByRole('tab', { name: '能力与工具' })
)
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: tab })
)
}
class DecodableBrandImage {
naturalWidth = 64
naturalHeight = 64
@@ -1619,7 +1630,7 @@ describe('SettingsPanel runtime files', () => {
<Harness />
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
openCapabilitySettingsTab('MCP')
const noteServerToggle = await screen.findByRole('button', {
name: '展开服务器 笔记'
})
@@ -1642,7 +1653,7 @@ describe('SettingsPanel runtime files', () => {
})
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
openCapabilitySettingsTab('MCP')
await waitFor(() =>
expect(
screen
@@ -4802,7 +4813,7 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('智能心跳')).not.toBeInTheDocument()
})
it('shows Skills and MCP as first-class settings tabs', async () => {
it('groups Skills and MCP under Capabilities and tools', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
@@ -4813,7 +4824,30 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
const categoryTabs = screen.getByRole('tablist', {
name: '设置分类'
})
expect(
within(categoryTabs).queryByRole('tab', { name: 'Skills' })
).not.toBeInTheDocument()
expect(
within(categoryTabs).queryByRole('tab', { name: 'MCP' })
).not.toBeInTheDocument()
fireEvent.click(
within(categoryTabs).getByRole('tab', { name: '能力与工具' })
)
const capabilityTabs = screen.getByRole('tablist', {
name: '能力与工具设置'
})
expect(
within(capabilityTabs)
.getAllByRole('tab')
.map((tab) => tab.textContent)
).toEqual(['Skills', 'MCP'])
expect(
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('文档写作')).toBeInTheDocument()
expect(
screen.getByText(
@@ -4848,7 +4882,15 @@ describe('SettingsPanel runtime files', () => {
)
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
expect(
within(capabilityTabs).queryByRole('tab', {
name: '工具执行环境'
})
).not.toBeInTheDocument()
fireEvent.click(
within(capabilityTabs).getByRole('tab', { name: 'MCP' })
)
const mcpTabs = screen.getByRole('tablist', {
name: 'MCP 设置分类'
})
@@ -5160,7 +5202,7 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const addServer = await screen.findByRole('button', {
name: '添加 Server'
@@ -5235,22 +5277,24 @@ describe('SettingsPanel runtime files', () => {
})
it('opens an existing MCP Server in the same modal and returns focus on Escape', async () => {
getCapabilitySnapshot.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000301',
name: '团队知识服务',
description: '公司内部 MCP',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: true,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
getCapabilitySnapshot
.mockResolvedValueOnce(capabilitySnapshot)
.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000301',
name: '团队知识服务',
description: '公司内部 MCP',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: true,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
@@ -5261,7 +5305,7 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const editButton = await screen.findByRole('button', {
name: '编辑 团队知识服务'
@@ -5287,22 +5331,24 @@ describe('SettingsPanel runtime files', () => {
})
it('shows custom MCP tools under their expandable server after testing', async () => {
getCapabilitySnapshot.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000302',
name: '团队工具服务',
description: '公司内部工具',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: false,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
getCapabilitySnapshot
.mockResolvedValueOnce(capabilitySnapshot)
.mockResolvedValueOnce({
...capabilitySnapshot,
mcpServers: [
{
id: '00000000-0000-4000-8000-000000000302',
name: '团队工具服务',
description: '公司内部工具',
enabled: true,
allowDynamicTools: true,
assignments: ['model'],
secretConfigured: false,
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
})
vi.mocked(
window.goodbuddy.capabilities.testMcpServer
).mockResolvedValueOnce({
@@ -5352,7 +5398,7 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
openCapabilitySettingsTab('MCP')
fireEvent.click(screen.getByRole('tab', { name: '自定义 MCP' }))
const serverToggle = await screen.findByRole('button', {
name: '展开服务器 团队工具服务'
+4 -7
View File
@@ -41,9 +41,8 @@ import {
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile
} from '../../shared/contracts'
import { McpSettingsSection } from './McpSettingsSection'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSettingsSection'
import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
@@ -842,8 +841,7 @@ export function SettingsPanel({
activeTab === 'document-parsing' ||
activeTab === 'ssh-hosts' ||
activeTab === 'channels' ||
activeTab === 'skills' ||
activeTab === 'mcp' ||
activeTab === 'capabilities' ||
activeTab === 'about'
const savedConfiguredSettings = settings
@@ -3930,9 +3928,8 @@ export function SettingsPanel({
/>
</>
)}
{activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && (
<McpSettingsSection
{activeTab === 'capabilities' && (
<CapabilitiesAndToolsSettingsSection
magicNotesEnabled={magicNotesEnabled}
/>
)}
+29 -2
View File
@@ -46,11 +46,38 @@ export function SettingsCategoryHeader({
headingId?: string
}): React.JSX.Element {
const definition = settingsCategories[category]
return (
<SettingsSectionHeader
actions={actions}
description={definition.description}
error={error}
headingId={headingId}
title={definition.label}
/>
)
}
export function SettingsSectionHeader({
actions,
description,
error,
headingLevel = 2,
headingId,
title
}: {
actions?: ReactNode
description: string
error?: string
headingLevel?: 2 | 3
headingId: string
title: string
}): React.JSX.Element {
const Heading = headingLevel === 3 ? 'h3' : 'h2'
return (
<header className="settings-category-header">
<div className="settings-category-header__content">
<h2 id={headingId}>{definition.label}</h2>
<p>{definition.description}</p>
<Heading id={headingId}>{title}</Heading>
<p>{description}</p>
</div>
{actions && (
<div className="settings-category-header__actions">
+5 -3
View File
@@ -6,7 +6,7 @@ import type {
CapabilitySnapshot,
RuntimeTarget
} from '../../shared/capability-contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { SettingsSectionHeader } from './SettingsPrimitives'
export function SkillsSettingsSection(): React.JSX.Element {
const { t } = useTranslation('settingsSections')
@@ -62,7 +62,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
return (
<>
<SettingsCategoryHeader
<SettingsSectionHeader
actions={
<>
<button
@@ -93,9 +93,11 @@ export function SkillsSettingsSection(): React.JSX.Element {
</button>
</>
}
category="skills"
description={t('skills.description')}
error={error}
headingLevel={3}
headingId="skills-settings-heading"
title={t('skills.title')}
/>
<section
aria-label={t('skills.listLabel')}
@@ -100,6 +100,15 @@ describe('WorkspacePrimitives', () => {
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps Capabilities and tools tabs visible in scrollable Settings', () => {
const capabilitiesTabs = stylesheet.match(
/\.capabilities-settings__tabs\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(capabilitiesTabs).toBeDefined()
expect(capabilitiesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => {
expect(stylesheet).toMatch(
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
@@ -145,6 +145,9 @@ export const integrations = {
save: 'Save channel settings'
},
mcp: {
title: 'MCP',
description:
'View built-in tools and MCP servers, and manage external MCP servers',
runtimeLabels: {
model: 'Model',
opencode: 'OpenCode',
+11 -11
View File
@@ -73,17 +73,10 @@ export const settings = {
navigationDescription: 'Roles, descriptions, and system prompts',
description: 'Roles, descriptions, and system prompts'
},
skills: {
label: 'Skills',
navigationDescription: 'Built-in and custom capabilities',
description:
'Works with direct models, OpenCode, Continue, and DeepSeek Harness'
},
mcp: {
label: 'MCP',
navigationDescription: 'Tool servers and credentials',
description:
'View built-in tools and MCP servers, and manage external MCP servers'
capabilities: {
label: 'Capabilities and tools',
navigationDescription: 'Skills and MCP',
description: 'Manage Skills and MCP'
},
about: {
label: 'About and updates',
@@ -92,6 +85,13 @@ export const settings = {
'Checks the selected official update source and never installs automatically'
}
},
capabilities: {
tabs: {
ariaLabel: 'Capabilities and tools settings',
skills: 'Skills',
mcp: 'MCP'
}
},
actions: {
testing: 'Testing…',
saveAndTestModel: 'Save and test model',
@@ -698,6 +698,9 @@ export const settingsSections = {
}
},
skills: {
title: 'Skills',
description:
'Works with direct models, OpenCode, Continue, and DeepSeek Harness',
runtimeLabels: {
model: 'Model',
opencode: 'OpenCode',
@@ -134,6 +134,8 @@ export const integrations = {
save: '保存通道设置'
},
mcp: {
title: 'MCP',
description: '查看内置工具、内置 MCP,并管理外部 MCP Server',
runtimeLabels: {
model: '模型',
opencode: 'OpenCode',
@@ -62,15 +62,10 @@ export const settings = {
navigationDescription: '角色、说明与系统提示词',
description: '角色、说明与系统提示词'
},
skills: {
label: 'Skills',
navigationDescription: '内置与自定义能力',
description: '支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
},
mcp: {
label: 'MCP',
navigationDescription: '工具服务与凭据',
description: '查看内置工具、内置 MCP,并管理外部 MCP Server'
capabilities: {
label: '能力与工具',
navigationDescription: 'Skills 与 MCP',
description: '管理 Skills 和 MCP'
},
about: {
label: '关于与更新',
@@ -78,6 +73,13 @@ export const settings = {
description: '检查所选官方更新源,不自动下载安装'
}
},
capabilities: {
tabs: {
ariaLabel: '能力与工具设置',
skills: 'Skills',
mcp: 'MCP'
}
},
actions: {
testing: '测试中…',
saveAndTestModel: '保存并测试模型',
@@ -652,6 +652,8 @@ export const settingsSections = {
}
},
skills: {
title: 'Skills',
description: '支持直连模型、OpenCode、Continue 和 DeepSeek Harness',
runtimeLabels: {
model: '模型',
opencode: 'OpenCode',
+2 -6
View File
@@ -42,12 +42,8 @@ export const settingsCategoryList = [
translationKey: 'roles'
},
{
id: 'skills',
translationKey: 'skills'
},
{
id: 'mcp',
translationKey: 'mcp'
id: 'capabilities',
translationKey: 'capabilities'
},
{
id: 'about',
+22 -1
View File
@@ -7615,7 +7615,7 @@ button > svg {
min-width: 0;
}
.settings-category-header__content h2 {
.settings-category-header__content :is(h2, h3) {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
@@ -10432,6 +10432,27 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.capabilities-settings__tabs {
max-width: 100%;
overflow-x: auto;
flex: 0 0 auto;
}
.capabilities-settings__tabs > .page-tabs {
min-width: max-content;
}
.capabilities-settings__panel {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-4);
}
.capabilities-settings__panel[hidden] {
display: none;
}
.mcp-tool-section {
display: flex;
flex-direction: column;