mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Settings needed a faster way to find local skills, and the plugin overview was wasting space by forcing summary cards into a tall right rail. Keep skill discovery client-side over the already-loaded list and flatten plugin metrics into a compact header so release notes can point issue #513 at the exact user-facing fix. Fixes #513 Constraint: Settings skills data is already loaded locally through /api/skills Rejected: Add server-side skill search | unnecessary API surface for a small local filter Confidence: high Scope-risk: narrow Directive: Keep Settings extension browsing read-only unless an install/edit flow is explicitly added Tested: bun run check:desktop Related: #513
326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useSkillStore } from '../../stores/skillStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { SkillMeta, SkillSource } from '../../types/skill'
|
|
|
|
const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']
|
|
|
|
const SOURCE_ICONS: Record<SkillSource, string> = {
|
|
user: 'person',
|
|
project: 'folder',
|
|
plugin: 'extension',
|
|
mcp: 'hub',
|
|
bundled: 'inventory_2',
|
|
}
|
|
|
|
const SOURCE_ACCENT_CLASSES: Record<SkillSource, string> = {
|
|
user: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]',
|
|
project: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
|
|
plugin: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]',
|
|
mcp: 'bg-[var(--color-info-container)] text-[var(--color-info)]',
|
|
bundled: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
|
|
}
|
|
|
|
function estimateTokens(contentLength: number) {
|
|
return Math.ceil(contentLength / 4)
|
|
}
|
|
|
|
export function SkillList() {
|
|
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
|
useSkillStore()
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
|
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<Record<SkillSource, SkillMeta[]>> = {}
|
|
for (const skill of filteredSkills) {
|
|
const src = skill.source as SkillSource
|
|
;(result[src] ??= []).push(skill)
|
|
}
|
|
return result
|
|
}, [filteredSkills])
|
|
|
|
const totalTokens = useMemo(
|
|
() => filteredSkills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
|
|
[filteredSkills],
|
|
)
|
|
|
|
const visibleGroupCount = useMemo(
|
|
() => SOURCE_ORDER.filter((source) => (grouped[source] ?? []).length > 0).length,
|
|
[grouped],
|
|
)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
|
|
}
|
|
|
|
if (skills.length === 0) {
|
|
return (
|
|
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
|
|
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
|
|
auto_awesome
|
|
</span>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.skills.empty')}
|
|
</p>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.skills.emptyHint')}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 min-w-0">
|
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
|
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
|
|
<div className="min-w-0">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
|
{t('settings.skills.browserEyebrow')}
|
|
</div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
|
auto_awesome
|
|
</span>
|
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.skills.browserTitle')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
|
|
{t('settings.skills.browserDescription')}
|
|
</p>
|
|
<div className="mt-4 max-w-2xl">
|
|
<label className="sr-only" htmlFor="settings-skill-search">
|
|
{t('settings.skills.searchLabel')}
|
|
</label>
|
|
<div className="flex min-h-11 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/20">
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
|
search
|
|
</span>
|
|
<input
|
|
id="settings-skill-search"
|
|
value={searchQuery}
|
|
onChange={(event) => 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 && (
|
|
<button
|
|
type="button"
|
|
aria-label={t('settings.skills.clearSearch')}
|
|
onClick={() => setSearchQuery('')}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{normalizedSearchQuery && (
|
|
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{t('settings.skills.searchResultCount', {
|
|
count: String(filteredSkills.length),
|
|
total: String(skills.length),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
|
|
<SummaryCard
|
|
label={t('settings.skills.summary.totalSkills')}
|
|
value={String(filteredSkills.length)}
|
|
icon="auto_awesome"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.skills.summary.sources')}
|
|
value={String(
|
|
SOURCE_ORDER.filter((source) => (grouped[source] ?? []).length > 0)
|
|
.length,
|
|
)}
|
|
icon="layers"
|
|
/>
|
|
<SummaryCard
|
|
label={t('settings.skills.summary.tokens')}
|
|
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
|
|
icon="notes"
|
|
className="col-span-2 sm:col-span-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{filteredSkills.length === 0 && (
|
|
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
|
|
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
|
|
search_off
|
|
</span>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.skills.noSearchResults')}
|
|
</p>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
|
{t('settings.skills.noSearchResultsHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`grid gap-4 ${visibleGroupCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
|
|
{SOURCE_ORDER.map((source) => {
|
|
const group = grouped[source]
|
|
if (!group?.length) return null
|
|
|
|
const sourceLabel = t(`settings.skills.source.${source}`)
|
|
const sourceTokenCount = group.reduce(
|
|
(sum, skill) => sum + estimateTokens(skill.contentLength),
|
|
0,
|
|
)
|
|
|
|
return (
|
|
<section
|
|
key={source}
|
|
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${SOURCE_ACCENT_CLASSES[source]}`}>
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{SOURCE_ICONS[source]}
|
|
</span>
|
|
</span>
|
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{sourceLabel}
|
|
</h4>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
|
{group.length}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
|
{t('settings.skills.groupHint', {
|
|
source: sourceLabel,
|
|
count: String(group.length),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)] whitespace-nowrap">
|
|
{t('settings.skills.tokenEstimateShort', { count: String(sourceTokenCount) })}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col p-2">
|
|
{group.map((skill) => (
|
|
<button
|
|
key={`${skill.source}-${skill.name}`}
|
|
onClick={() =>
|
|
skill.hasDirectory &&
|
|
fetchSkillDetail(skill.source, skill.name, currentWorkDir, 'skills')
|
|
}
|
|
disabled={!skill.hasDirectory}
|
|
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
|
auto_awesome
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
|
|
{skill.displayName || skill.name}
|
|
</span>
|
|
{skill.version && (
|
|
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
v{skill.version}
|
|
</span>
|
|
)}
|
|
{skill.userInvocable && (
|
|
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
{t('settings.skills.slashCommand')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
|
{skill.description}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<span>{sourceLabel}</span>
|
|
<span>{t('settings.skills.tokenEstimateShort', { count: String(estimateTokens(skill.contentLength)) })}</span>
|
|
<span>{skill.hasDirectory ? t('settings.skills.ready') : t('settings.skills.unavailable')}</span>
|
|
</div>
|
|
</div>
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
|
|
chevron_right
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
className = '',
|
|
}: {
|
|
label: string
|
|
value: string
|
|
icon: string
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
|
|
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|