diff --git a/desktop/src/components/market/InstalledSkillsOverview.test.tsx b/desktop/src/components/market/InstalledSkillsOverview.test.tsx new file mode 100644 index 00000000..b0c4d2b5 --- /dev/null +++ b/desktop/src/components/market/InstalledSkillsOverview.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useSkillStore } from '../../stores/skillStore' +import type { SkillMeta } from '../../types/skill' +import { InstalledSkillsOverview } from './InstalledSkillsOverview' + +const fetchSkills = vi.fn().mockResolvedValue(undefined) +const fetchSkillDetail = vi.fn().mockResolvedValue(undefined) + +function makeSkill(name: string, source: SkillMeta['source']): SkillMeta { + return { + name: name.toLocaleLowerCase(), + displayName: name, + description: `${name} description`, + source, + userInvocable: true, + version: '1.0.0', + contentLength: 240, + hasDirectory: true, + } +} + +const installedSkills = [ + makeSkill('Alpha', 'user'), + makeSkill('Beta', 'project'), + makeSkill('Gamma', 'plugin'), + makeSkill('Delta', 'mcp'), + makeSkill('Epsilon', 'bundled'), + makeSkill('Zeta', 'user'), + makeSkill('Eta', 'bundled'), + makeSkill('Theta', 'plugin'), +] + +describe('InstalledSkillsOverview', () => { + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [{ + id: 'session-1', + title: 'Current project', + createdAt: '2026-07-22T00:00:00.000Z', + modifiedAt: '2026-07-22T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: 'session-1', + isLoading: false, + error: null, + }) + useSkillStore.setState({ + skills: installedSkills, + selectedSkill: null, + selectedSkillReturnTab: 'skills', + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills, + fetchSkillDetail, + clearSelection: vi.fn(), + }) + }) + + it('loads real installed skills for the active project and opens local details', () => { + render() + + expect(fetchSkills).toHaveBeenCalledWith('/workspace/project') + expect(screen.getByTestId('installed-skills-overview')).toBeInTheDocument() + expect(screen.getByText('Installed')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Alpha' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) + + expect(fetchSkillDetail).toHaveBeenCalledWith('user', 'alpha', '/workspace/project', 'skills') + }) + + it('collapses long lists, searches locally, and filters personal versus system skills', () => { + render() + + expect(screen.getByRole('button', { name: 'View 2 more' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'View 2 more' })) + expect(screen.getByRole('button', { name: 'Zeta' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Personal' })) + expect(screen.getByRole('button', { name: 'Alpha' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Beta' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Gamma' })).not.toBeInTheDocument() + + fireEvent.change(screen.getByTestId('installed-skills-search'), { target: { value: 'zeta' } }) + expect(screen.getByRole('button', { name: 'Zeta' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Alpha' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'System' })) + expect(screen.getByText('No matching skills')).toBeInTheDocument() + }) + it('keeps cached installed skills visible when a background refresh fails', () => { + useSkillStore.setState({ error: 'Request timed out after 120s' }) + + render() + + expect(screen.getByRole('button', { name: 'Alpha' })).toBeInTheDocument() + expect(screen.queryByText('Request timed out after 120s')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/InstalledSkillsOverview.tsx b/desktop/src/components/market/InstalledSkillsOverview.tsx new file mode 100644 index 00000000..93ce8a03 --- /dev/null +++ b/desktop/src/components/market/InstalledSkillsOverview.tsx @@ -0,0 +1,246 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Blocks, + Check, + Folder, + Network, + PackageOpen, + RefreshCw, + Search, + Sparkles, + UserRound, + X, + type LucideIcon, +} from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useSessionStore } from '../../stores/sessionStore' +import { useSkillStore } from '../../stores/skillStore' +import type { SkillMeta, SkillSource } from '../../types/skill' + +type InstalledSkillFilter = 'all' | 'personal' | 'system' + +const COLLAPSED_SKILL_COUNT = 6 + +const SOURCE_ICONS: Record = { + user: UserRound, + project: Folder, + plugin: Blocks, + mcp: Network, + bundled: PackageOpen, +} + +const PERSONAL_SOURCES = new Set(['user', 'project']) + +function skillMatchesFilter(skill: SkillMeta, filter: InstalledSkillFilter) { + if (filter === 'all') return true + const personal = PERSONAL_SOURCES.has(skill.source) + return filter === 'personal' ? personal : !personal +} + +export function InstalledSkillsOverview() { + const t = useTranslation() + const skills = useSkillStore((state) => state.skills) + const isLoading = useSkillStore((state) => state.isLoading) + const error = useSkillStore((state) => state.error) + const fetchSkills = useSkillStore((state) => state.fetchSkills) + const fetchSkillDetail = useSkillStore((state) => state.fetchSkillDetail) + const sessions = useSessionStore((state) => state.sessions) + const activeSessionId = useSessionStore((state) => state.activeSessionId) + const activeSession = sessions.find((session) => session.id === activeSessionId) + const currentWorkDir = activeSession?.workDir || undefined + const [query, setQuery] = useState('') + const [filter, setFilter] = useState('all') + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + void fetchSkills(currentWorkDir) + }, [currentWorkDir, fetchSkills]) + + const filteredSkills = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase() + return skills + .filter((skill) => skillMatchesFilter(skill, filter)) + .filter((skill) => { + if (!normalizedQuery) return true + return [ + skill.name, + skill.displayName, + skill.description, + skill.pluginName, + t(`settings.skills.source.${skill.source}`), + ].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)) + }) + .sort((left, right) => + (left.displayName || left.name).localeCompare(right.displayName || right.name), + ) + }, [filter, query, skills, t]) + + const shouldCollapse = !query.trim() && filteredSkills.length > COLLAPSED_SKILL_COUNT + const visibleSkills = shouldCollapse && !expanded + ? filteredSkills.slice(0, COLLAPSED_SKILL_COUNT) + : filteredSkills + const hiddenCount = filteredSkills.length - visibleSkills.length + + return ( +
+
+
+

+ {t('market.section.installed')} +

+ {!isLoading && ( + + {skills.length} + + )} +
+ +
+ +
+
+
+ +
+ {(['all', 'personal', 'system'] as const).map((value) => ( + + ))} +
+
+ + {isLoading && skills.length === 0 && ( +
+ {Array.from({ length: 6 }, (_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && error && skills.length === 0 && ( +
+

{error}

+ +
+ )} + + {!isLoading && !error && skills.length === 0 && ( +
+
+ )} + + {!isLoading && skills.length > 0 && filteredSkills.length === 0 && ( +
+

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

+
+ )} + + {visibleSkills.length > 0 && ( +
+ {visibleSkills.map((skill) => { + const Icon = SOURCE_ICONS[skill.source] + const name = skill.displayName || skill.name + return ( + + ) + })} +
+ )} + + {shouldCollapse && ( + + )} +
+ ) +} diff --git a/desktop/src/components/market/MarketHome.test.tsx b/desktop/src/components/market/MarketHome.test.tsx index fce15341..b689136a 100644 --- a/desktop/src/components/market/MarketHome.test.tsx +++ b/desktop/src/components/market/MarketHome.test.tsx @@ -7,6 +7,10 @@ import { useMarketStore } from '../../stores/marketStore' import type { NormalizedSkill } from '../../types/market' import { MarketHome } from './MarketHome' +vi.mock('./InstalledSkillsOverview', () => ({ + InstalledSkillsOverview: () =>
Installed skills overview
, +})) + function makeSkill(overrides: Partial = {}): NormalizedSkill { return { id: 'clawhub:demo', @@ -48,6 +52,7 @@ describe('MarketHome', () => { it('renders the compact catalog header, command bar, sources and semantic cards', () => { render() + expect(screen.getByText('Installed skills overview')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument() expect(screen.getByTestId('market-search-input')).toBeInTheDocument() expect(screen.getByTestId('market-search-input')).toHaveAttribute('data-slot', 'input') diff --git a/desktop/src/components/market/MarketHome.tsx b/desktop/src/components/market/MarketHome.tsx index 5bcd98da..0e0e22ad 100644 --- a/desktop/src/components/market/MarketHome.tsx +++ b/desktop/src/components/market/MarketHome.tsx @@ -14,6 +14,7 @@ import { FilterBar } from './FilterBar' import { MarketDisclaimer } from './MarketDisclaimer' import { SkillCard } from './SkillCard' import { SourceStatusBar } from './SourceStatusBar' +import { InstalledSkillsOverview } from './InstalledSkillsOverview' export function MarketHome({ onRequestInstall, @@ -72,6 +73,8 @@ export function MarketHome({
+ + diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9aeab018..73bfebab 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -2409,6 +2409,15 @@ export const en = { 'sidebar.market': 'Skills Market', 'market.title': 'Skills Market', 'market.subtitle': 'Browse, preview and install skills from ClawHub and SkillHub.', + 'market.section.installed': 'Installed', + 'market.installedSkills.refresh': 'Refresh installed skills', + 'market.installedSkills.searchPlaceholder': 'Search installed skills', + 'market.installedSkills.filterLabel': 'Filter installed skills', + 'market.installedSkills.filter.all': 'All', + 'market.installedSkills.filter.personal': 'Personal', + 'market.installedSkills.filter.system': 'System', + 'market.installedSkills.showMore': 'View {count} more', + 'market.installedSkills.showLess': 'Show less', 'market.searchPlaceholder': 'Search skills by name, keyword…', 'market.clearSearch': 'Clear search', 'market.resultCount': '{count} skills', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 76bf2171..d448fc1a 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2411,6 +2411,15 @@ export const jp: Record = { 'sidebar.market': 'スキルマーケット', 'market.title': 'スキルマーケット', 'market.subtitle': 'ClawHub と SkillHub のスキルを閲覧・プレビュー・インストールできます。', + 'market.section.installed': 'インストール済み', + 'market.installedSkills.refresh': 'インストール済みスキルを更新', + 'market.installedSkills.searchPlaceholder': 'インストール済みスキルを検索', + 'market.installedSkills.filterLabel': 'インストール済みスキルを絞り込む', + 'market.installedSkills.filter.all': 'すべて', + 'market.installedSkills.filter.personal': '個人', + 'market.installedSkills.filter.system': 'システム', + 'market.installedSkills.showMore': '他 {count} 件を表示', + 'market.installedSkills.showLess': '折りたたむ', 'market.searchPlaceholder': 'スキル名・キーワードで検索…', 'market.clearSearch': '検索をクリア', 'market.resultCount': '{count} 件のスキル', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 0bff4c3a..4ac57eaa 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2411,6 +2411,15 @@ export const kr: Record = { 'sidebar.market': '스킬 마켓', 'market.title': '스킬 마켓', 'market.subtitle': 'ClawHub와 SkillHub의 스킬을 탐색·미리보기·설치할 수 있습니다.', + 'market.section.installed': '설치됨', + 'market.installedSkills.refresh': '설치된 스킬 새로고침', + 'market.installedSkills.searchPlaceholder': '설치된 스킬 검색', + 'market.installedSkills.filterLabel': '설치된 스킬 필터', + 'market.installedSkills.filter.all': '전체', + 'market.installedSkills.filter.personal': '개인', + 'market.installedSkills.filter.system': '시스템', + 'market.installedSkills.showMore': '{count}개 더 보기', + 'market.installedSkills.showLess': '접기', 'market.searchPlaceholder': '스킬 이름, 키워드로 검색…', 'market.clearSearch': '검색 지우기', 'market.resultCount': '{count}개의 스킬', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 4e736b04..16df2a9f 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2411,6 +2411,15 @@ export const zh: Record = { 'sidebar.market': '技能市集', 'market.title': '技能市集', 'market.subtitle': '瀏覽、預覽並安裝來自 ClawHub 與 SkillHub 的技能。', + 'market.section.installed': '已安裝', + 'market.installedSkills.refresh': '重新整理已安裝技能', + 'market.installedSkills.searchPlaceholder': '搜尋已安裝技能', + 'market.installedSkills.filterLabel': '篩選已安裝技能', + 'market.installedSkills.filter.all': '全部', + 'market.installedSkills.filter.personal': '個人', + 'market.installedSkills.filter.system': '系統', + 'market.installedSkills.showMore': '查看另外 {count} 項', + 'market.installedSkills.showLess': '收合', 'market.searchPlaceholder': '依名稱、關鍵字搜尋技能…', 'market.clearSearch': '清除搜尋', 'market.resultCount': '{count} 個技能', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index d8c932b5..4188a7e4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2411,6 +2411,15 @@ export const zh: Record = { 'sidebar.market': '技能市场', 'market.title': '技能市场', 'market.subtitle': '浏览、预览并安装来自 ClawHub 与 SkillHub 的技能。', + 'market.section.installed': '已安装', + 'market.installedSkills.refresh': '刷新已安装技能', + 'market.installedSkills.searchPlaceholder': '搜索已安装技能', + 'market.installedSkills.filterLabel': '筛选已安装技能', + 'market.installedSkills.filter.all': '全部', + 'market.installedSkills.filter.personal': '个人', + 'market.installedSkills.filter.system': '系统', + 'market.installedSkills.showMore': '查看另外 {count} 项', + 'market.installedSkills.showLess': '收起', 'market.searchPlaceholder': '按名称、关键词搜索技能…', 'market.clearSearch': '清除搜索', 'market.resultCount': '{count} 个技能',