fix: Improve settings extension browsing

Settings needed a faster way to find local skills, and the plugin overview was wasting space by forcing summary cards into a tall right rail. Keep skill discovery client-side over the already-loaded list and flatten plugin metrics into a compact header so release notes can point issue #513 at the exact user-facing fix.

Fixes #513

Constraint: Settings skills data is already loaded locally through /api/skills
Rejected: Add server-side skill search | unnecessary API surface for a small local filter
Confidence: high
Scope-risk: narrow
Directive: Keep Settings extension browsing read-only unless an install/edit flow is explicitly added
Tested: bun run check:desktop
Related: #513
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 16:07:07 +08:00
parent 55d4c80823
commit e1c2f54f14
5 changed files with 185 additions and 59 deletions

View File

@ -144,6 +144,47 @@ describe('Settings > Skills tab', () => {
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
})
it('filters installed skills locally by keyword and clears the search', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
contentLength: 400,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
})
render(<Settings />)
switchToSkillsTab()
const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...')
fireEvent.change(searchInput, { target: { value: 'telegram' } })
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Clear skill search'))
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
})
it('uses the active session workDir even when settings tab is focused', () => {
const fetchSkills = vi.fn()
useSkillStore.setState({

View File

@ -102,57 +102,26 @@ export function PluginList() {
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 2xl:grid-cols-[minmax(0,1.45fr)_minmax(420px,0.95fr)] 2xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
extension
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.plugins.browserDescription')}
</p>
{lastReloadSummary && (
<p className="mt-3 text-xs text-[var(--color-text-tertiary)]">
{t('settings.plugins.lastReload', {
enabled: String(lastReloadSummary.enabled),
skills: String(lastReloadSummary.skills),
errors: String(lastReloadSummary.errors),
})}
<div className="flex flex-col gap-4 px-5 py-5 min-w-0">
<div className="flex flex-col gap-4 min-w-0 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 max-w-4xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
extension
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.plugins.browserDescription')}
</p>
)}
</div>
<div className="flex flex-col gap-3 min-w-0 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(120px,1fr))] gap-3">
<SummaryCard
label={t('settings.plugins.summary.total')}
value={String(summary?.total ?? plugins.length)}
icon="extension"
/>
<SummaryCard
label={t('settings.plugins.summary.enabled')}
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
icon="check_circle"
/>
<SummaryCard
label={t('settings.plugins.summary.attention')}
value={String(grouped.attention.length)}
icon="warning"
/>
<SummaryCard
label={t('settings.plugins.summary.marketplaces')}
value={String(summary?.marketplaceCount ?? marketplaces.length)}
icon="storefront"
/>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<div className="flex flex-wrap gap-2 xl:justify-end">
<Button
variant="secondary"
size="sm"
@ -173,6 +142,39 @@ export function PluginList() {
</Button>
</div>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 md:grid-cols-4">
<SummaryCard
label={t('settings.plugins.summary.total')}
value={String(summary?.total ?? plugins.length)}
icon="extension"
/>
<SummaryCard
label={t('settings.plugins.summary.enabled')}
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
icon="check_circle"
/>
<SummaryCard
label={t('settings.plugins.summary.attention')}
value={String(grouped.attention.length)}
icon="warning"
/>
<SummaryCard
label={t('settings.plugins.summary.marketplaces')}
value={String(summary?.marketplaceCount ?? marketplaces.length)}
icon="storefront"
/>
</div>
{lastReloadSummary && (
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.plugins.lastReload', {
enabled: String(lastReloadSummary.enabled),
skills: String(lastReloadSummary.skills),
errors: String(lastReloadSummary.errors),
})}
</p>
)}
</div>
</section>
@ -326,14 +328,14 @@ function SummaryCard({
icon: string
}) {
return (
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="flex min-w-0 items-start gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="min-w-0 break-words text-[10px] leading-4 whitespace-normal">
<span className="min-w-0 truncate text-[10px] leading-4">
{label}
</span>
</div>
<div className="mt-2 truncate text-xl font-semibold text-[var(--color-text-primary)]">
<div className="mt-1.5 truncate text-lg font-semibold text-[var(--color-text-primary)]">
{value}
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
@ -34,23 +34,45 @@ export function SkillList() {
const t = useTranslation()
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
const [searchQuery, setSearchQuery] = useState('')
const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase()
useEffect(() => {
fetchSkills(currentWorkDir)
}, [fetchSkills, currentWorkDir])
const filteredSkills = useMemo(() => {
if (!normalizedSearchQuery) return skills
return skills.filter((skill) => {
const fields = [
skill.name,
skill.displayName,
skill.description,
skill.source,
t(`settings.skills.source.${skill.source}`),
skill.version,
skill.pluginName,
]
return fields.some((field) =>
field?.toLocaleLowerCase().includes(normalizedSearchQuery),
)
})
}, [skills, normalizedSearchQuery, t])
const grouped = useMemo(() => {
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
for (const skill of skills) {
for (const skill of filteredSkills) {
const src = skill.source as SkillSource
;(result[src] ??= []).push(skill)
}
return result
}, [skills])
}, [filteredSkills])
const totalTokens = useMemo(
() => skills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
[skills],
() => filteredSkills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
[filteredSkills],
)
const visibleGroupCount = useMemo(
@ -105,12 +127,47 @@ export function SkillList() {
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.skills.browserDescription')}
</p>
<div className="mt-4 max-w-2xl">
<label className="sr-only" htmlFor="settings-skill-search">
{t('settings.skills.searchLabel')}
</label>
<div className="flex min-h-11 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/20">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
search
</span>
<input
id="settings-skill-search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('settings.skills.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{searchQuery && (
<button
type="button"
aria-label={t('settings.skills.clearSearch')}
onClick={() => setSearchQuery('')}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
)}
</div>
{normalizedSearchQuery && (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
{t('settings.skills.searchResultCount', {
count: String(filteredSkills.length),
total: String(skills.length),
})}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard
label={t('settings.skills.summary.totalSkills')}
value={String(skills.length)}
value={String(filteredSkills.length)}
icon="auto_awesome"
/>
<SummaryCard
@ -131,6 +188,20 @@ export function SkillList() {
</div>
</section>
{filteredSkills.length === 0 && (
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
search_off
</span>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.skills.noSearchResults')}
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.skills.noSearchResultsHint')}
</p>
</div>
)}
<div className={`grid gap-4 ${visibleGroupCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
{SOURCE_ORDER.map((source) => {
const group = grouped[source]

View File

@ -579,6 +579,12 @@ export const en = {
'settings.skills.browserTitle': 'Browse installed skills',
'settings.skills.browserEyebrow': 'Skill Browser',
'settings.skills.browserDescription': 'Inspect bundled, project, and user skills, compare their scope, and open each skill folder to read its docs and source files.',
'settings.skills.searchLabel': 'Search skills',
'settings.skills.searchPlaceholder': 'Search skills by name, description, or source...',
'settings.skills.searchResultCount': '{count} of {total} skills match',
'settings.skills.clearSearch': 'Clear skill search',
'settings.skills.noSearchResults': 'No matching skills',
'settings.skills.noSearchResultsHint': 'Try another keyword or clear search to show every skill.',
'settings.skills.entryEyebrow': 'Skill Entry',
'settings.skills.slashCommand': '/slash',
'settings.skills.tokenEstimate': '~{count} tokens',

View File

@ -581,6 +581,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.browserTitle': '浏览已安装技能',
'settings.skills.browserEyebrow': '技能浏览器',
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
'settings.skills.searchLabel': '搜索技能',
'settings.skills.searchPlaceholder': '搜索技能名称、描述或来源...',
'settings.skills.searchResultCount': '匹配 {count} / {total} 个技能',
'settings.skills.clearSearch': '清空技能搜索',
'settings.skills.noSearchResults': '没有匹配的技能',
'settings.skills.noSearchResultsHint': '换一个关键词,或清空搜索查看全部技能。',
'settings.skills.entryEyebrow': '技能入口',
'settings.skills.slashCommand': '/斜杠命令',
'settings.skills.tokenEstimate': '约 {count} tokens',