mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-03 17:01:15 +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
6b7e90aec7
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 type { NormalizedSkill } from '../../types/market'
|
||||||
import { MarketHome } from './MarketHome'
|
import { MarketHome } from './MarketHome'
|
||||||
|
|
||||||
|
vi.mock('./InstalledSkillsOverview', () => ({
|
||||||
|
InstalledSkillsOverview: () => <section>Installed skills overview</section>,
|
||||||
|
}))
|
||||||
|
|
||||||
function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
|
function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
|
||||||
return {
|
return {
|
||||||
id: 'clawhub:demo',
|
id: 'clawhub:demo',
|
||||||
@ -48,6 +52,7 @@ describe('MarketHome', () => {
|
|||||||
it('renders the compact catalog header, command bar, sources and semantic cards', () => {
|
it('renders the compact catalog header, command bar, sources and semantic cards', () => {
|
||||||
render(<MarketHome onRequestInstall={vi.fn()} />)
|
render(<MarketHome onRequestInstall={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Installed skills overview')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
|
expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('market-search-input')).toHaveAttribute('data-slot', 'input')
|
expect(screen.getByTestId('market-search-input')).toHaveAttribute('data-slot', 'input')
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { FilterBar } from './FilterBar'
|
|||||||
import { MarketDisclaimer } from './MarketDisclaimer'
|
import { MarketDisclaimer } from './MarketDisclaimer'
|
||||||
import { SkillCard } from './SkillCard'
|
import { SkillCard } from './SkillCard'
|
||||||
import { SourceStatusBar } from './SourceStatusBar'
|
import { SourceStatusBar } from './SourceStatusBar'
|
||||||
|
import { InstalledSkillsOverview } from './InstalledSkillsOverview'
|
||||||
|
|
||||||
export function MarketHome({
|
export function MarketHome({
|
||||||
onRequestInstall,
|
onRequestInstall,
|
||||||
@ -72,6 +73,8 @@ export function MarketHome({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
|
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
|
||||||
|
<InstalledSkillsOverview />
|
||||||
|
|
||||||
<MarketDisclaimer />
|
<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">
|
<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">
|
||||||
|
|||||||
@ -2409,6 +2409,15 @@ export const en = {
|
|||||||
'sidebar.market': 'Skills Market',
|
'sidebar.market': 'Skills Market',
|
||||||
'market.title': 'Skills Market',
|
'market.title': 'Skills Market',
|
||||||
'market.subtitle': 'Browse, preview and install skills from ClawHub and SkillHub.',
|
'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.searchPlaceholder': 'Search skills by name, keyword…',
|
||||||
'market.clearSearch': 'Clear search',
|
'market.clearSearch': 'Clear search',
|
||||||
'market.resultCount': '{count} skills',
|
'market.resultCount': '{count} skills',
|
||||||
|
|||||||
@ -2411,6 +2411,15 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'sidebar.market': 'スキルマーケット',
|
'sidebar.market': 'スキルマーケット',
|
||||||
'market.title': 'スキルマーケット',
|
'market.title': 'スキルマーケット',
|
||||||
'market.subtitle': 'ClawHub と SkillHub のスキルを閲覧・プレビュー・インストールできます。',
|
'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.searchPlaceholder': 'スキル名・キーワードで検索…',
|
||||||
'market.clearSearch': '検索をクリア',
|
'market.clearSearch': '検索をクリア',
|
||||||
'market.resultCount': '{count} 件のスキル',
|
'market.resultCount': '{count} 件のスキル',
|
||||||
|
|||||||
@ -2411,6 +2411,15 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'sidebar.market': '스킬 마켓',
|
'sidebar.market': '스킬 마켓',
|
||||||
'market.title': '스킬 마켓',
|
'market.title': '스킬 마켓',
|
||||||
'market.subtitle': 'ClawHub와 SkillHub의 스킬을 탐색·미리보기·설치할 수 있습니다.',
|
'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.searchPlaceholder': '스킬 이름, 키워드로 검색…',
|
||||||
'market.clearSearch': '검색 지우기',
|
'market.clearSearch': '검색 지우기',
|
||||||
'market.resultCount': '{count}개의 스킬',
|
'market.resultCount': '{count}개의 스킬',
|
||||||
|
|||||||
@ -2411,6 +2411,15 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'sidebar.market': '技能市集',
|
'sidebar.market': '技能市集',
|
||||||
'market.title': '技能市集',
|
'market.title': '技能市集',
|
||||||
'market.subtitle': '瀏覽、預覽並安裝來自 ClawHub 與 SkillHub 的技能。',
|
'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.searchPlaceholder': '依名稱、關鍵字搜尋技能…',
|
||||||
'market.clearSearch': '清除搜尋',
|
'market.clearSearch': '清除搜尋',
|
||||||
'market.resultCount': '{count} 個技能',
|
'market.resultCount': '{count} 個技能',
|
||||||
|
|||||||
@ -2411,6 +2411,15 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'sidebar.market': '技能市场',
|
'sidebar.market': '技能市场',
|
||||||
'market.title': '技能市场',
|
'market.title': '技能市场',
|
||||||
'market.subtitle': '浏览、预览并安装来自 ClawHub 与 SkillHub 的技能。',
|
'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.searchPlaceholder': '按名称、关键词搜索技能…',
|
||||||
'market.clearSearch': '清除搜索',
|
'market.clearSearch': '清除搜索',
|
||||||
'market.resultCount': '{count} 个技能',
|
'market.resultCount': '{count} 个技能',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user