diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 0ecefea1..e7f034c7 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -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() + 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({ diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx index e7b5d4a0..d3189b63 100644 --- a/desktop/src/components/plugins/PluginList.tsx +++ b/desktop/src/components/plugins/PluginList.tsx @@ -102,57 +102,26 @@ export function PluginList() { return (
-
-
-
- {t('settings.plugins.browserEyebrow')} -
-
- - extension - -

- {t('settings.plugins.browserTitle')} -

-
-

- {t('settings.plugins.browserDescription')} -

- {lastReloadSummary && ( -

- {t('settings.plugins.lastReload', { - enabled: String(lastReloadSummary.enabled), - skills: String(lastReloadSummary.skills), - errors: String(lastReloadSummary.errors), - })} +

+
+
+
+ {t('settings.plugins.browserEyebrow')} +
+
+ + extension + +

+ {t('settings.plugins.browserTitle')} +

+
+

+ {t('settings.plugins.browserDescription')}

- )} -
- -
-
- - plugin.enabled).length)} - icon="check_circle" - /> - -
-
+ +
+ +
+ + plugin.enabled).length)} + icon="check_circle" + /> + + +
+ + {lastReloadSummary && ( +

+ {t('settings.plugins.lastReload', { + enabled: String(lastReloadSummary.enabled), + skills: String(lastReloadSummary.skills), + errors: String(lastReloadSummary.errors), + })} +

+ )}
@@ -326,14 +328,14 @@ function SummaryCard({ icon: string }) { return ( -
-
+
+
{icon} - + {label}
-
+
{value}
diff --git a/desktop/src/components/skills/SkillList.tsx b/desktop/src/components/skills/SkillList.tsx index 1a8a4eb5..39fe80ad 100644 --- a/desktop/src/components/skills/SkillList.tsx +++ b/desktop/src/components/skills/SkillList.tsx @@ -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> = {} - 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() {

{t('settings.skills.browserDescription')}

+
+ +
+ + search + + 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 && ( + + )} +
+ {normalizedSearchQuery && ( +

+ {t('settings.skills.searchResultCount', { + count: String(filteredSkills.length), + total: String(skills.length), + })} +

+ )} +
+ {filteredSkills.length === 0 && ( +
+ + search_off + +

+ {t('settings.skills.noSearchResults')} +

+

+ {t('settings.skills.noSearchResultsHint')} +

+
+ )} +
= 2 ? 'xl:grid-cols-2' : ''}`}> {SOURCE_ORDER.map((source) => { const group = grouped[source] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b123b898..58f25da2 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 79c33349..9fb6e331 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -581,6 +581,12 @@ export const zh: Record = { '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',