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() 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', () => { it('uses the active session workDir even when settings tab is focused', () => {
const fetchSkills = vi.fn() const fetchSkills = vi.fn()
useSkillStore.setState({ useSkillStore.setState({

View File

@ -102,57 +102,26 @@ export function PluginList() {
return ( return (
<div className="flex flex-col gap-6 min-w-0"> <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"> <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="flex flex-col gap-4 px-5 py-5 min-w-0">
<div className="min-w-0"> <div className="flex flex-col gap-4 min-w-0 xl:flex-row xl:items-start xl:justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2"> <div className="min-w-0 max-w-4xl">
{t('settings.plugins.browserEyebrow')} <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
</div> {t('settings.plugins.browserEyebrow')}
<div className="flex items-center gap-3 mb-2"> </div>
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]"> <div className="flex items-center gap-3 mb-2">
extension <span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
</span> extension
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </span>
{t('settings.plugins.browserTitle')} <h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
</h3> {t('settings.plugins.browserTitle')}
</div> </h3>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl"> </div>
{t('settings.plugins.browserDescription')} <p className="text-sm leading-6 text-[var(--color-text-secondary)]">
</p> {t('settings.plugins.browserDescription')}
{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),
})}
</p> </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>
<div className="flex flex-wrap gap-2 sm:justify-end">
<div className="flex flex-wrap gap-2 xl:justify-end">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@ -173,6 +142,39 @@ export function PluginList() {
</Button> </Button>
</div> </div>
</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> </div>
</section> </section>
@ -326,14 +328,14 @@ function SummaryCard({
icon: string icon: string
}) { }) {
return ( 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="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-start gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]"> <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="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} {label}
</span> </span>
</div> </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} {value}
</div> </div>
</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 { useSkillStore } from '../../stores/skillStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@ -34,23 +34,45 @@ export function SkillList() {
const t = useTranslation() const t = useTranslation()
const activeSession = sessions.find((session) => session.id === activeSessionId) const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined const currentWorkDir = activeSession?.workDir || undefined
const [searchQuery, setSearchQuery] = useState('')
const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase()
useEffect(() => { useEffect(() => {
fetchSkills(currentWorkDir) fetchSkills(currentWorkDir)
}, [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 grouped = useMemo(() => {
const result: Partial<Record<SkillSource, SkillMeta[]>> = {} const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
for (const skill of skills) { for (const skill of filteredSkills) {
const src = skill.source as SkillSource const src = skill.source as SkillSource
;(result[src] ??= []).push(skill) ;(result[src] ??= []).push(skill)
} }
return result return result
}, [skills]) }, [filteredSkills])
const totalTokens = useMemo( const totalTokens = useMemo(
() => skills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0), () => filteredSkills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
[skills], [filteredSkills],
) )
const visibleGroupCount = useMemo( const visibleGroupCount = useMemo(
@ -105,12 +127,47 @@ export function SkillList() {
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl"> <p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.skills.browserDescription')} {t('settings.skills.browserDescription')}
</p> </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>
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard <SummaryCard
label={t('settings.skills.summary.totalSkills')} label={t('settings.skills.summary.totalSkills')}
value={String(skills.length)} value={String(filteredSkills.length)}
icon="auto_awesome" icon="auto_awesome"
/> />
<SummaryCard <SummaryCard
@ -131,6 +188,20 @@ export function SkillList() {
</div> </div>
</section> </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' : ''}`}> <div className={`grid gap-4 ${visibleGroupCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
{SOURCE_ORDER.map((source) => { {SOURCE_ORDER.map((source) => {
const group = grouped[source] const group = grouped[source]

View File

@ -579,6 +579,12 @@ export const en = {
'settings.skills.browserTitle': 'Browse installed skills', 'settings.skills.browserTitle': 'Browse installed skills',
'settings.skills.browserEyebrow': 'Skill Browser', '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.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.entryEyebrow': 'Skill Entry',
'settings.skills.slashCommand': '/slash', 'settings.skills.slashCommand': '/slash',
'settings.skills.tokenEstimate': '~{count} tokens', 'settings.skills.tokenEstimate': '~{count} tokens',

View File

@ -581,6 +581,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.browserTitle': '浏览已安装技能', 'settings.skills.browserTitle': '浏览已安装技能',
'settings.skills.browserEyebrow': '技能浏览器', 'settings.skills.browserEyebrow': '技能浏览器',
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。', '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.entryEyebrow': '技能入口',
'settings.skills.slashCommand': '/斜杠命令', 'settings.skills.slashCommand': '/斜杠命令',
'settings.skills.tokenEstimate': '约 {count} tokens', 'settings.skills.tokenEstimate': '约 {count} tokens',