fix: unify settings center layout

This commit is contained in:
lofyer
2026-08-10 22:30:32 +08:00
parent 7d15e83153
commit 2cc76fc960
11 changed files with 430 additions and 428 deletions
+36 -31
View File
@@ -1,7 +1,6 @@
import {
FlaskConical,
FolderOpen,
MessageSquare,
Save,
Smartphone,
Unplug
@@ -34,6 +33,7 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
type ChannelDraft = {
enabled: boolean
@@ -1218,40 +1218,44 @@ export function ChannelSettingsSection({
!dingtalkProject
) {
return (
<div className="settings-section">
<p className={error ? 'settings-warning' : 'settings-empty'}>
{error ?? '正在读取消息通道设置…'}
</p>
</div>
<>
<SettingsCategoryHeader
category="channels"
error={error}
headingId="channel-settings-heading"
/>
{!error && (
<div className="settings-section">
<p className="settings-empty"></p>
</div>
)}
</>
)
}
return (
<section
aria-labelledby="channel-settings-heading"
className="settings-section channel-settings"
>
<div className="settings-section__title settings-section__title--actions">
<MessageSquare aria-hidden="true" size={17} />
<div>
<strong id="channel-settings-heading"></strong>
<small>
</small>
</div>
<button
className="primary-button"
disabled={busy}
onClick={() => void save()}
type="button"
>
<Save aria-hidden="true" size={13} />
{busy ? '保存中…' : '保存通道设置'}
</button>
</div>
<>
<SettingsCategoryHeader
actions={
<button
className="primary-button"
disabled={busy}
onClick={() => void save()}
type="button"
>
<Save aria-hidden="true" size={13} />
{busy ? '保存中…' : '保存通道设置'}
</button>
}
category="channels"
error={error}
headingId="channel-settings-heading"
/>
<section
aria-label="消息通道配置"
className="settings-section channel-settings"
>
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
{error && <p className="settings-warning" role="alert">{error}</p>}
<div className="channel-settings__tabs">
<PageTabs
@@ -1325,6 +1329,7 @@ export function ChannelSettingsSection({
/>
)}
</div>
</section>
</section>
</>
)
}
+23 -22
View File
@@ -29,6 +29,7 @@ import type {
RuntimeTarget
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import { SettingsCategoryHeader } from './SettingsPrimitives'
const runtimeLabels: Record<RuntimeTarget, string> = {
model: '模型',
@@ -318,25 +319,26 @@ export function McpSettingsSection(): React.JSX.Element {
}
return (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
<Network size={17} />
<div>
<strong> MCP</strong>
<small> MCP MCP Server</small>
</div>
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus size={14} />
Server
</button>
</div>
<>
<SettingsCategoryHeader
actions={
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus aria-hidden="true" size={14} />
Server
</button>
}
category="mcp"
error={!editor ? error : undefined}
headingId="mcp-settings-heading"
/>
<section aria-label="MCP 配置" className="settings-section">
<p className="settings-notice">
MCP Execute
@@ -348,8 +350,6 @@ export function McpSettingsSection(): React.JSX.Element {
访
GoodBuddy
</p>
{error && !editor && <p className="settings-warning">{error}</p>}
<section
aria-labelledby="computer-capabilities-heading"
className="mcp-tool-section"
@@ -1108,6 +1108,7 @@ export function McpSettingsSection(): React.JSX.Element {
)
})}
</div>
</div>
</section>
</>
)
}
@@ -1,4 +1,3 @@
import { Sparkles } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
ApplicationSettings,
@@ -6,6 +5,7 @@ import type {
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -108,17 +108,13 @@ export function PlatformFeaturesSettingsSection({
}
return (
<section
aria-labelledby="platform-features-heading"
className="settings-section"
>
<div className="settings-section__title">
<Sparkles aria-hidden="true" size={17} />
<div>
<strong id="platform-features-heading"></strong>
<small> GoodBuddy </small>
</div>
</div>
<>
<SettingsCategoryHeader
category="platform-features"
error={error}
headingId="platform-features-heading"
/>
<section aria-label="平台功能选项" className="settings-section">
<article className="capability-card">
<div className="capability-card__header">
<div>
@@ -175,11 +171,7 @@ export function PlatformFeaturesSettingsSection({
</small>
</div>
</article>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
</section>
</section>
</>
)
}
+52 -7
View File
@@ -476,6 +476,7 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentMode: 'after-save-auto'
})
)
expect(
screen.getByRole('button', { name: '长评 + 要点' })
).toHaveAttribute('aria-pressed', 'true')
@@ -532,7 +533,7 @@ describe('SettingsPanel runtime files', () => {
).toBeNull()
})
it('places explicit configuration actions at the top of the content', () => {
it('uses one category header for titles and explicit actions', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
@@ -548,18 +549,24 @@ describe('SettingsPanel runtime files', () => {
name: '设置中心'
})
const content = screen.getByRole('tabpanel')
const toolbar = content.querySelector(
'.settings-panel__content-toolbar'
const categoryHeader = content.querySelector(
'.settings-category-header'
)
expect(toolbar).toBe(content.firstElementChild)
expect(categoryHeader).toBe(content.firstElementChild)
expect(
within(toolbar as HTMLElement).getByRole('button', {
within(categoryHeader as HTMLElement).getByRole('heading', {
level: 2,
name: 'Agent Runtime'
})
).toBeInTheDocument()
expect(
within(categoryHeader as HTMLElement).getByRole('button', {
name: '保存设置'
})
).toBeInTheDocument()
expect(
within(toolbar as HTMLElement).getByRole('button', {
within(categoryHeader as HTMLElement).getByRole('button', {
name: '保存并测试 OpenCode'
})
).toBeInTheDocument()
@@ -571,6 +578,35 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.queryByRole('button', { name: '保存设置' })
).not.toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '外观' })
).toBeInTheDocument()
})
it('routes save success through the transient app notification', async () => {
const onNotify = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()}
/>
)
await screen.findByDisplayValue('C:\\Workspace')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '设置已保存',
dedupeKey: 'runtime-settings-saved'
})
)
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
})
it('uses one first-level heading for the settings page', () => {
@@ -1386,6 +1422,7 @@ describe('SettingsPanel runtime files', () => {
})
it('tests the selected model instead of a selected Continue Runtime', async () => {
const onNotify = vi.fn()
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
provider: 'continue',
@@ -1405,6 +1442,7 @@ describe('SettingsPanel runtime files', () => {
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()}
/>
)
@@ -1419,7 +1457,14 @@ describe('SettingsPanel runtime files', () => {
expect(testModelConnection).toHaveBeenCalledWith(modelProfileId)
)
expect(testRuntime).not.toHaveBeenCalled()
expect(await screen.findByText('连接成功:sonnet-5')).toBeInTheDocument()
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '连接成功:sonnet-5',
dedupeKey: 'model-connection-tested'
})
)
expect(screen.queryByText('连接成功:sonnet-5')).not.toBeInTheDocument()
})
it('shows an actionable model error without Electron IPC prefixes', async () => {
+100 -270
View File
@@ -1,5 +1,4 @@
import {
Check,
FolderOpen,
KeyRound,
LockKeyhole,
@@ -39,6 +38,11 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
settingsCategoryList,
type SettingsCategoryId
} from './settings-categories'
import type { AppearanceTheme } from './theme'
import type { AppNotificationInput } from './notifications'
import type {
@@ -46,18 +50,6 @@ import type {
EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts'
type SettingsTab =
| 'appearance'
| 'platform-features'
| 'model'
| 'runtime'
| 'security'
| 'automation'
| 'channels'
| 'roles'
| 'skills'
| 'mcp'
| 'about'
type ModelType = 'llm' | 'embedding' | 'speech'
type AgentRuntimeType = RuntimeConfigActionInput['runtime']
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
@@ -66,19 +58,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
clearApiKey: boolean
}
const settingsTabs: readonly SettingsTab[] = [
'appearance',
'platform-features',
'model',
'runtime',
'security',
'automation',
'channels',
'roles',
'skills',
'mcp',
'about'
]
const settingsTabs = settingsCategoryList.map(({ id }) => id)
type SettingsPanelProps = {
open: boolean
@@ -253,7 +233,7 @@ export function SettingsPanel({
presentation = 'modal',
onClose,
onSaved,
onNotify,
onNotify = () => {},
onClearLocalData,
heartbeats,
onCreateHeartbeat,
@@ -333,13 +313,12 @@ export function SettingsPanel({
const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] =
useState(false)
const [error, setError] = useState<string>()
const [saved, setSaved] = useState(false)
const [connectionResult, setConnectionResult] = useState<string>()
const [confirmingClear, setConfirmingClear] = useState(false)
const [clearingLocalData, setClearingLocalData] = useState(false)
const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime')
const [activeTab, setActiveTab] =
useState<SettingsCategoryId>('runtime')
const [modelType, setModelType] = useState<ModelType>('llm')
const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode')
@@ -349,12 +328,16 @@ export function SettingsPanel({
activeTab === 'runtime' ||
activeTab === 'security' ||
activeTab === 'roles'
const showContentActions =
configurationTab || Boolean(error) || saved
const categoryRendersOwnHeader =
activeTab === 'platform-features' ||
activeTab === 'channels' ||
activeTab === 'skills' ||
activeTab === 'mcp' ||
activeTab === 'about'
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsTab
tab: SettingsCategoryId
): void => {
const currentIndex = settingsTabs.indexOf(tab)
let nextIndex: number | undefined
@@ -374,6 +357,7 @@ export function SettingsPanel({
}
event.preventDefault()
const nextTab = settingsTabs[nextIndex]!
setError(undefined)
setActiveTab(nextTab)
event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>(
@@ -390,8 +374,6 @@ export function SettingsPanel({
.getRuntime()
.then((value) => {
setError(undefined)
setSaved(false)
setConnectionResult(undefined)
setConfirmingClear(false)
setClearingLocalData(false)
setModelType('llm')
@@ -496,10 +478,11 @@ export function SettingsPanel({
onClose()
}
const save = async (): Promise<RuntimeSettings | undefined> => {
const save = async (
notifySuccess = true
): Promise<RuntimeSettings | undefined> => {
setSaving(true)
setError(undefined)
setSaved(false)
try {
const defaultProfile =
modelProfiles.find(
@@ -605,8 +588,14 @@ export function SettingsPanel({
)
}
}
setSaved(true)
onSaved(value)
if (notifySuccess) {
onNotify({
tone: 'success',
message: '设置已保存',
dedupeKey: 'runtime-settings-saved'
})
}
return value
} catch (reason) {
setError(settingsErrorMessage(reason, '保存设置失败'))
@@ -620,8 +609,7 @@ export function SettingsPanel({
const testingModel = activeTab === 'model' && modelType === 'llm'
const profileId = selectedModelProfileId
setTesting(true)
setConnectionResult(undefined)
const savedSettings = await save()
const savedSettings = await save(false)
if (!savedSettings) {
setTesting(false)
return
@@ -644,11 +632,16 @@ export function SettingsPanel({
if (!status.available) {
throw new Error(status.detail)
}
setConnectionResult(
status.capability === 'image-generation'
? status.detail
: `连接成功:${status.label}`
)
onNotify({
tone: 'success',
message:
status.capability === 'image-generation'
? status.detail
: `连接成功:${status.label}`,
dedupeKey: testingModel
? 'model-connection-tested'
: `runtime-connection-tested-${agentRuntimeType}`
})
} catch (reason) {
setError(
settingsErrorMessage(
@@ -671,7 +664,7 @@ export function SettingsPanel({
setEmbeddingDiagnostic(undefined)
setError(undefined)
try {
if (!(await save())) {
if (!(await save(false))) {
return
}
const diagnostic = await embeddings.diagnose()
@@ -692,7 +685,7 @@ export function SettingsPanel({
}
setError(undefined)
try {
if (!(await save())) {
if (!(await save(false))) {
return
}
const indexStatus = await embeddings.rebuild()
@@ -993,182 +986,28 @@ export function SettingsPanel({
className="settings-tabs"
role="tablist"
>
<button
aria-controls="settings-panel-appearance"
aria-label="外观"
aria-selected={activeTab === 'appearance'}
id="settings-tab-appearance"
onClick={() => setActiveTab('appearance')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'appearance')
}
role="tab"
tabIndex={activeTab === 'appearance' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-platform-features"
aria-label="平台功能"
aria-selected={activeTab === 'platform-features'}
id="settings-tab-platform-features"
onClick={() => setActiveTab('platform-features')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'platform-features')
}
role="tab"
tabIndex={activeTab === 'platform-features' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-model"
aria-label="模型连接"
aria-selected={activeTab === 'model'}
id="settings-tab-model"
onClick={() => setActiveTab('model')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'model')
}
role="tab"
tabIndex={activeTab === 'model' ? 0 : -1}
type="button"
>
<strong></strong>
<small>LLM</small>
</button>
<button
aria-controls="settings-panel-runtime"
aria-label="Agent Runtime"
aria-selected={activeTab === 'runtime'}
id="settings-tab-runtime"
onClick={() => setActiveTab('runtime')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'runtime')
}
role="tab"
tabIndex={activeTab === 'runtime' ? 0 : -1}
type="button"
>
<strong>Agent Runtime</strong>
<small>OpenCodeContinue </small>
</button>
<button
aria-controls="settings-panel-security"
aria-label="安全与数据"
aria-selected={activeTab === 'security'}
id="settings-tab-security"
onClick={() => setActiveTab('security')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'security')
}
role="tab"
tabIndex={activeTab === 'security' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-automation"
aria-label="自动化"
aria-selected={activeTab === 'automation'}
id="settings-tab-automation"
onClick={() => setActiveTab('automation')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'automation')
}
role="tab"
tabIndex={activeTab === 'automation' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-channels"
aria-label="消息通道"
aria-selected={activeTab === 'channels'}
id="settings-tab-channels"
onClick={() => setActiveTab('channels')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'channels')
}
role="tab"
tabIndex={activeTab === 'channels' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-roles"
aria-label="角色与提示词"
aria-selected={activeTab === 'roles'}
id="settings-tab-roles"
onClick={() => setActiveTab('roles')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'roles')
}
role="tab"
tabIndex={activeTab === 'roles' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-skills"
aria-label="Skills"
aria-selected={activeTab === 'skills'}
id="settings-tab-skills"
onClick={() => setActiveTab('skills')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'skills')
}
role="tab"
tabIndex={activeTab === 'skills' ? 0 : -1}
type="button"
>
<strong>Skills</strong>
<small></small>
</button>
<button
aria-controls="settings-panel-mcp"
aria-label="MCP"
aria-selected={activeTab === 'mcp'}
id="settings-tab-mcp"
onClick={() => setActiveTab('mcp')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'mcp')
}
role="tab"
tabIndex={activeTab === 'mcp' ? 0 : -1}
type="button"
>
<strong>MCP</strong>
<small></small>
</button>
<button
aria-controls="settings-panel-about"
aria-label="关于与更新"
aria-selected={activeTab === 'about'}
id="settings-tab-about"
onClick={() => setActiveTab('about')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'about')
}
role="tab"
tabIndex={activeTab === 'about' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
{settingsCategoryList.map((category) => (
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={category.label}
aria-selected={activeTab === category.id}
id={`settings-tab-${category.id}`}
key={category.id}
onClick={() => {
setError(undefined)
setActiveTab(category.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
}
role="tab"
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<strong>{category.label}</strong>
<small>{category.navigationDescription}</small>
</button>
))}
</nav>
<div
@@ -1178,52 +1017,43 @@ export function SettingsPanel({
ref={settingsBodyRef}
role="tabpanel"
>
{showContentActions && (
<div className="settings-panel__content-toolbar">
<div className="settings-feedback">
{error && (
<span className="settings-error" role="alert">
{error}
</span>
)}
{saved && (
<span className="settings-success" role="status">
<Check aria-hidden="true" size={14} />
{connectionResult ?? '设置已保存'}
</span>
)}
</div>
{configurationTab && (
<div className="settings-panel__content-actions">
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
{!categoryRendersOwnHeader && (
<SettingsCategoryHeader
actions={
configurationTab ? (
<>
{(activeTab === 'runtime' ||
(activeTab === 'model' && modelType === 'llm')) && (
<button
className="secondary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button
className="secondary-button"
className="primary-button"
disabled={saving || testing}
onClick={() => void testConnection()}
onClick={() => void save()}
type="button"
>
{testing
? '测试中…'
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
<Save aria-hidden="true" size={13} />
{saving ? '保存中…' : '保存设置'}
</button>
)}
<button
className="primary-button"
disabled={saving || testing}
onClick={() => void save()}
type="button"
>
<Save aria-hidden="true" size={13} />
{saving ? '保存中…' : '保存设置'}
</button>
</div>
)}
</div>
</>
) : undefined
}
category={activeTab}
error={error}
/>
)}
{activeTab === 'appearance' && (
<div className="settings-section appearance-settings">
@@ -2215,7 +2045,8 @@ export function SettingsPanel({
{activeTab === 'security' && (
<>
<label className="field">
<div className="settings-section">
<label className="field">
<span>Runtime OS </span>
<select
aria-label="Runtime OS 沙箱"
@@ -2235,8 +2066,8 @@ export function SettingsPanel({
bubblewrap Linux
OpenCode Runtime Continue
</small>
</label>
<label className="field">
</label>
<label className="field">
<span></span>
<select
aria-label="直连模型工具安全策略"
@@ -2258,7 +2089,8 @@ export function SettingsPanel({
OpenCode
Continue 使
</small>
</label>
</label>
</div>
<div className="settings-section settings-section--danger">
<div>
@@ -2284,8 +2116,6 @@ export function SettingsPanel({
onClick={() => {
setClearingLocalData(true)
setError(undefined)
setSaved(false)
setConnectionResult(undefined)
void onClearLocalData()
.then(() => {
setConfirmingClear(false)
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import {
settingsCategories,
type SettingsCategoryId
} from './settings-categories'
export function SettingsCategoryHeader({
actions,
category,
error,
headingId = `settings-category-${category}`
}: {
actions?: ReactNode
category: SettingsCategoryId
error?: string
headingId?: string
}): React.JSX.Element {
const definition = settingsCategories[category]
return (
<header className="settings-category-header">
<div className="settings-category-header__content">
<h2 id={headingId}>{definition.label}</h2>
<p>{definition.description}</p>
</div>
{actions && (
<div className="settings-category-header__actions">
{actions}
</div>
)}
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
</header>
)
}
+41 -37
View File
@@ -1,10 +1,11 @@
import { BookOpen, Download, Trash2 } from 'lucide-react'
import { Download, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
CapabilityAssignments,
CapabilitySnapshot,
RuntimeTarget
} from '../../shared/capability-contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
const runtimeLabels: Record<RuntimeTarget, string> = {
model: '模型',
@@ -56,46 +57,48 @@ export function SkillsSettingsSection(): React.JSX.Element {
}
return (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
<BookOpen size={17} />
<div>
<strong>Skills</strong>
<small>OpenCode Continue</small>
</div>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download size={14} />
Skill
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download size={14} />
Skill ZIP
</button>
</div>
<>
<SettingsCategoryHeader
actions={
<>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
Skill
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
Skill ZIP
</button>
</>
}
category="skills"
error={error}
headingId="skills-settings-heading"
/>
<section aria-label="Skills 列表" className="settings-section">
<p className="settings-notice">
Skill Runtime
Skill OpenCode Continue
</p>
{error && <p className="settings-warning">{error}</p>}
{!snapshot && !error && <p className="settings-empty"> Skills</p>}
<div className="capability-list">
{snapshot?.skills.map((skill) => (
@@ -174,6 +177,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
</article>
))}
</div>
</div>
</section>
</>
)
}
+14 -18
View File
@@ -1,10 +1,11 @@
import { ExternalLink, Info, RefreshCw } from 'lucide-react'
import { ExternalLink, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
ApplicationSettings,
VersionCheckResult
} from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
@@ -106,17 +107,16 @@ export function UpdateSettingsSection(): React.JSX.Element {
}
return (
<section
aria-labelledby="update-settings-heading"
className="settings-section update-settings"
>
<div className="settings-section__title">
<Info aria-hidden="true" size={17} />
<div>
<strong id="update-settings-heading"></strong>
<small> GoodBuddy GitHub Release</small>
</div>
</div>
<>
<SettingsCategoryHeader
category="about"
error={error}
headingId="update-settings-heading"
/>
<section
aria-label="更新设置"
className="settings-section update-settings"
>
<article className="capability-card">
<div className="capability-card__header">
@@ -165,11 +165,6 @@ export function UpdateSettingsSection(): React.JSX.Element {
</div>
</article>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
{result && (
<article
aria-live="polite"
@@ -202,6 +197,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
</p>
</article>
)}
</section>
</section>
</>
)
}
+76
View File
@@ -0,0 +1,76 @@
export const settingsCategoryList = [
{
id: 'appearance',
label: '外观',
navigationDescription: '亮色、暗色与系统主题',
description: '亮色、暗色与系统主题'
},
{
id: 'platform-features',
label: '平台功能',
navigationDescription: '功能入口与工作区能力',
description: '控制 GoodBuddy 工作区中显示的功能入口'
},
{
id: 'model',
label: '模型连接',
navigationDescription: 'LLM、向量模型与凭据',
description: 'LLM、向量模型与凭据'
},
{
id: 'runtime',
label: 'Agent Runtime',
navigationDescription: 'OpenCode、Continue 与工作区',
description: 'OpenCode、Continue 与工作区'
},
{
id: 'security',
label: '安全与数据',
navigationDescription: '工具策略与本地隐私',
description: '工具策略与本地隐私'
},
{
id: 'automation',
label: '自动化',
navigationDescription: '智能心跳与周期回顾',
description: '智能心跳与周期回顾'
},
{
id: 'channels',
label: '消息通道',
navigationDescription: '微信、企业微信与钉钉',
description: '配置连接、工作目录、消息处理后端与默认模式'
},
{
id: 'roles',
label: '角色与提示词',
navigationDescription: '角色、说明与系统提示词',
description: '角色、说明与系统提示词'
},
{
id: 'skills',
label: 'Skills',
navigationDescription: '内置与自定义能力',
description: '支持直连模型、OpenCode 和 Continue'
},
{
id: 'mcp',
label: 'MCP',
navigationDescription: '工具服务与凭据',
description: '查看内置工具、内置 MCP,并管理外部 MCP Server'
},
{
id: 'about',
label: '关于与更新',
navigationDescription: '版本检查与下载页',
description: '只检查 GoodBuddy 官方 GitHub Release,不自动下载安装'
}
] as const
export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id']
export const settingsCategories = Object.fromEntries(
settingsCategoryList.map((category) => [category.id, category])
) as Record<SettingsCategoryId, SettingsCategoryDefinition>
+37 -22
View File
@@ -4065,15 +4065,34 @@ button > svg * {
scrollbar-gutter: stable;
}
.settings-panel__content-toolbar {
display: flex;
min-height: var(--control-height);
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
.settings-category-header {
display: grid;
min-width: 0;
align-items: start;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-3) var(--space-4);
}
.settings-panel__content-actions {
.settings-category-header__content {
min-width: 0;
}
.settings-category-header__content h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
font-weight: 650;
line-height: 1.4;
}
.settings-category-header__content p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.settings-category-header__actions {
display: flex;
align-items: center;
justify-content: flex-end;
@@ -4081,13 +4100,19 @@ button > svg * {
gap: var(--space-2);
}
.settings-panel__content-actions .primary-button {
.settings-category-header__actions .primary-button,
.settings-category-header__actions .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.settings-category-header > .settings-warning {
margin: 0;
grid-column: 1 / -1;
}
.settings-page .settings-tabs button {
min-height: 58px;
padding: 10px 12px;
@@ -5567,12 +5592,6 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--success) !important;
}
.settings-feedback {
flex: 1;
min-width: 0;
font-size: 10px;
}
.settings-error {
color: var(--danger);
}
@@ -8773,17 +8792,13 @@ details.settings-section > :not(summary) + :not(summary) {
flex-basis: 100%;
}
.settings-panel__content-toolbar {
align-items: stretch;
flex-wrap: wrap;
.settings-category-header {
grid-template-columns: 1fr;
}
.settings-feedback {
flex-basis: 100%;
}
.settings-panel__content-actions {
.settings-category-header__actions {
width: 100%;
justify-content: flex-start;
}
.mcp-editor {