mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
Merge pull request #1098 from RaspberryLee/feat/installed-skills-overview
feat(desktop): show installed skills in the Skills Market
This commit is contained in:
commit
4dfeb26c25
109
desktop/src/components/market/InstalledSkillsOverview.test.tsx
Normal file
109
desktop/src/components/market/InstalledSkillsOverview.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
246
desktop/src/components/market/InstalledSkillsOverview.tsx
Normal file
246
desktop/src/components/market/InstalledSkillsOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
@ -47,6 +51,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-filter-bar')).toBeInTheDocument()
|
||||
|
||||
@ -6,6 +6,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 }: { onRequestInstall: (id: string) => void }) {
|
||||
const t = useTranslation()
|
||||
@ -57,6 +58,8 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
|
||||
</header>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
|
||||
<InstalledSkillsOverview />
|
||||
|
||||
<MarketDisclaimer />
|
||||
|
||||
<section className="sticky top-0 z-20 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-glass)] p-2.5 shadow-[0_8px_24px_rgba(27,28,26,0.06)] backdrop-blur-xl">
|
||||
|
||||
@ -2350,6 +2350,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',
|
||||
|
||||
@ -2352,6 +2352,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} 件のスキル',
|
||||
|
||||
@ -2352,6 +2352,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}개의 스킬',
|
||||
|
||||
@ -2352,6 +2352,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} 個技能',
|
||||
|
||||
@ -2352,6 +2352,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} 个技能',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user