Merge pull request #1098 from RaspberryLee/feat/installed-skills-overview

feat(desktop): show installed skills in the Skills Market
This commit is contained in:
程序员阿江(Relakkes) 2026-07-25 16:11:43 +08:00
commit 6b7e90aec7
9 changed files with 408 additions and 0 deletions

View File

@ -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(<InstalledSkillsOverview />)
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(<InstalledSkillsOverview />)
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(<InstalledSkillsOverview />)
expect(screen.getByRole('button', { name: 'Alpha' })).toBeInTheDocument()
expect(screen.queryByText('Request timed out after 120s')).not.toBeInTheDocument()
})
})

View File

@ -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<SkillSource, LucideIcon> = {
user: UserRound,
project: Folder,
plugin: Blocks,
mcp: Network,
bundled: PackageOpen,
}
const PERSONAL_SOURCES = new Set<SkillSource>(['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<InstalledSkillFilter>('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 (
<section data-testid="installed-skills-overview" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4 border-b border-[var(--color-border)]/60 pb-3">
<div className="flex min-w-0 items-baseline gap-2.5">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('market.section.installed')}
</h2>
{!isLoading && (
<span className="text-xs tabular-nums text-[var(--color-text-tertiary)]">
{skills.length}
</span>
)}
</div>
<button
type="button"
aria-label={t('market.installedSkills.refresh')}
title={t('market.installedSkills.refresh')}
disabled={isLoading}
onClick={() => void fetchSkills(currentWorkDir)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} strokeWidth={1.8} aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-h-10 flex-1 items-center gap-2.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
<Search className="h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
data-testid="installed-skills-search"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setExpanded(false)
}}
placeholder={t('market.installedSkills.searchPlaceholder')}
aria-label={t('market.installedSkills.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{query && (
<button
type="button"
aria-label={t('settings.skills.clearSearch')}
onClick={() => setQuery('')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-1 rounded-xl bg-[var(--color-surface-container-low)] p-1" aria-label={t('market.installedSkills.filterLabel')}>
{(['all', 'personal', 'system'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={filter === value}
onClick={() => {
setFilter(value)
setExpanded(false)
}}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
filter === value
? 'bg-[var(--color-surface)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
>
{t(`market.installedSkills.filter.${value}`)}
</button>
))}
</div>
</div>
{isLoading && skills.length === 0 && (
<div className="grid grid-cols-1 gap-x-10 gap-y-1 md:grid-cols-2" data-testid="installed-skills-loading">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex min-h-[72px] animate-pulse items-center gap-3 border-b border-[var(--color-border)]/45 px-2 py-3">
<div className="h-10 w-10 rounded-xl bg-[var(--color-surface-container-high)]" />
<div className="min-w-0 flex-1">
<div className="h-3 w-2/5 rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-2 h-2.5 w-4/5 rounded bg-[var(--color-surface-container)]" />
</div>
</div>
))}
</div>
)}
{!isLoading && error && skills.length === 0 && (
<div className="flex items-center justify-between gap-4 rounded-xl border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/25 px-4 py-3">
<p className="min-w-0 break-words text-sm text-[var(--color-error)]">{error}</p>
<button
type="button"
onClick={() => void fetchSkills(currentWorkDir)}
className="shrink-0 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('market.retry')}
</button>
</div>
)}
{!isLoading && !error && skills.length === 0 && (
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-10 text-center">
<Sparkles className="mx-auto mb-2.5 h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={1.5} aria-hidden="true" />
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.skills.empty')}</p>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.skills.emptyHint')}</p>
</div>
)}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-9 text-center">
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.skills.noSearchResults')}</p>
</div>
)}
{visibleSkills.length > 0 && (
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2" data-testid="installed-skills-grid">
{visibleSkills.map((skill) => {
const Icon = SOURCE_ICONS[skill.source]
const name = skill.displayName || skill.name
return (
<button
key={`${skill.source}:${skill.name}`}
type="button"
aria-label={name}
disabled={!skill.hasDirectory}
onClick={() => void fetchSkillDetail(skill.source, skill.name, currentWorkDir, 'skills')}
className="group flex min-h-[72px] min-w-0 items-center gap-3 border-b border-[var(--color-border)]/45 px-2 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-default disabled:opacity-55"
>
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] transition-colors group-hover:bg-[var(--color-surface-container)]">
<Icon className="h-[18px] w-[18px]" strokeWidth={1.7} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{name}</span>
{skill.version && (
<span className="shrink-0 text-[10px] text-[var(--color-text-tertiary)]">v{skill.version}</span>
)}
</span>
<span className="mt-0.5 block truncate text-xs leading-5 text-[var(--color-text-tertiary)]">
{skill.description}
</span>
</span>
<Check className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
</button>
)
})}
</div>
)}
{shouldCollapse && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="self-start rounded-lg px-2 py-1 text-xs font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
{expanded
? t('market.installedSkills.showLess')
: t('market.installedSkills.showMore', { count: hiddenCount })}
</button>
)}
</section>
)
}

View File

@ -7,6 +7,10 @@ import { useMarketStore } from '../../stores/marketStore'
import type { NormalizedSkill } from '../../types/market'
import { MarketHome } from './MarketHome'
vi.mock('./InstalledSkillsOverview', () => ({
InstalledSkillsOverview: () => <section>Installed skills overview</section>,
}))
function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
return {
id: 'clawhub:demo',
@ -48,6 +52,7 @@ describe('MarketHome', () => {
it('renders the compact catalog header, command bar, sources and semantic cards', () => {
render(<MarketHome onRequestInstall={vi.fn()} />)
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')

View File

@ -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({
</header>
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
<InstalledSkillsOverview />
<MarketDisclaimer />
<Card className="sticky top-0 z-20 bg-[var(--color-surface-glass)] shadow-[0_8px_24px_rgba(27,28,26,0.06)] backdrop-blur-xl">

View File

@ -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',

View File

@ -2411,6 +2411,15 @@ export const jp: Record<TranslationKey, string> = {
'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} 件のスキル',

View File

@ -2411,6 +2411,15 @@ export const kr: Record<TranslationKey, string> = {
'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}개의 스킬',

View File

@ -2411,6 +2411,15 @@ export const zh: Record<TranslationKey, string> = {
'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} 個技能',

View File

@ -2411,6 +2411,15 @@ export const zh: Record<TranslationKey, string> = {
'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} 个技能',