mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: Improve settings extension browsing
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
This commit is contained in:
parent
55d4c80823
commit
e1c2f54f14
@ -144,6 +144,47 @@ describe('Settings > Skills tab', () => {
|
||||
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters installed skills locally by keyword and clears the search', () => {
|
||||
useSkillStore.setState({
|
||||
skills: [
|
||||
{
|
||||
name: 'alpha',
|
||||
displayName: 'Alpha Skill',
|
||||
description: 'First skill description',
|
||||
source: 'user',
|
||||
userInvocable: true,
|
||||
contentLength: 400,
|
||||
hasDirectory: true,
|
||||
},
|
||||
{
|
||||
name: 'telegram:access',
|
||||
displayName: 'Telegram Access',
|
||||
description: 'Plugin-provided access workflow',
|
||||
source: 'plugin',
|
||||
pluginName: 'telegram',
|
||||
userInvocable: true,
|
||||
contentLength: 280,
|
||||
hasDirectory: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToSkillsTab()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...')
|
||||
fireEvent.change(searchInput, { target: { value: 'telegram' } })
|
||||
|
||||
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Clear skill search'))
|
||||
|
||||
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the active session workDir even when settings tab is focused', () => {
|
||||
const fetchSkills = vi.fn()
|
||||
useSkillStore.setState({
|
||||
|
||||
@ -102,57 +102,26 @@ export function PluginList() {
|
||||
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 2xl:grid-cols-[minmax(0,1.45fr)_minmax(420px,0.95fr)] 2xl: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.plugins.browserEyebrow')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
||||
extension
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.plugins.browserTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
|
||||
{t('settings.plugins.browserDescription')}
|
||||
</p>
|
||||
{lastReloadSummary && (
|
||||
<p className="mt-3 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.plugins.lastReload', {
|
||||
enabled: String(lastReloadSummary.enabled),
|
||||
skills: String(lastReloadSummary.skills),
|
||||
errors: String(lastReloadSummary.errors),
|
||||
})}
|
||||
<div className="flex flex-col gap-4 px-5 py-5 min-w-0">
|
||||
<div className="flex flex-col gap-4 min-w-0 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 max-w-4xl">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||
{t('settings.plugins.browserEyebrow')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
||||
extension
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.plugins.browserTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{t('settings.plugins.browserDescription')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 min-w-0 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(120px,1fr))] gap-3">
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.total')}
|
||||
value={String(summary?.total ?? plugins.length)}
|
||||
icon="extension"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.enabled')}
|
||||
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
|
||||
icon="check_circle"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.attention')}
|
||||
value={String(grouped.attention.length)}
|
||||
icon="warning"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.marketplaces')}
|
||||
value={String(summary?.marketplaceCount ?? marketplaces.length)}
|
||||
icon="storefront"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
|
||||
<div className="flex flex-wrap gap-2 xl:justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@ -173,6 +142,39 @@ export function PluginList() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.total')}
|
||||
value={String(summary?.total ?? plugins.length)}
|
||||
icon="extension"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.enabled')}
|
||||
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
|
||||
icon="check_circle"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.attention')}
|
||||
value={String(grouped.attention.length)}
|
||||
icon="warning"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.plugins.summary.marketplaces')}
|
||||
value={String(summary?.marketplaceCount ?? marketplaces.length)}
|
||||
icon="storefront"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastReloadSummary && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.plugins.lastReload', {
|
||||
enabled: String(lastReloadSummary.enabled),
|
||||
skills: String(lastReloadSummary.skills),
|
||||
errors: String(lastReloadSummary.errors),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -326,14 +328,14 @@ function SummaryCard({
|
||||
icon: string
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3">
|
||||
<div className="flex min-w-0 items-start gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
||||
<span className="min-w-0 break-words text-[10px] leading-4 whitespace-normal">
|
||||
<span className="min-w-0 truncate text-[10px] leading-4">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 truncate text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
<div className="mt-1.5 truncate text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -34,23 +34,45 @@ export function SkillList() {
|
||||
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 skills) {
|
||||
for (const skill of filteredSkills) {
|
||||
const src = skill.source as SkillSource
|
||||
;(result[src] ??= []).push(skill)
|
||||
}
|
||||
return result
|
||||
}, [skills])
|
||||
}, [filteredSkills])
|
||||
|
||||
const totalTokens = useMemo(
|
||||
() => skills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
|
||||
[skills],
|
||||
() => filteredSkills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
|
||||
[filteredSkills],
|
||||
)
|
||||
|
||||
const visibleGroupCount = useMemo(
|
||||
@ -105,12 +127,47 @@ export function SkillList() {
|
||||
<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(skills.length)}
|
||||
value={String(filteredSkills.length)}
|
||||
icon="auto_awesome"
|
||||
/>
|
||||
<SummaryCard
|
||||
@ -131,6 +188,20 @@ export function SkillList() {
|
||||
</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]
|
||||
|
||||
@ -579,6 +579,12 @@ export const en = {
|
||||
'settings.skills.browserTitle': 'Browse installed skills',
|
||||
'settings.skills.browserEyebrow': 'Skill Browser',
|
||||
'settings.skills.browserDescription': 'Inspect bundled, project, and user skills, compare their scope, and open each skill folder to read its docs and source files.',
|
||||
'settings.skills.searchLabel': 'Search skills',
|
||||
'settings.skills.searchPlaceholder': 'Search skills by name, description, or source...',
|
||||
'settings.skills.searchResultCount': '{count} of {total} skills match',
|
||||
'settings.skills.clearSearch': 'Clear skill search',
|
||||
'settings.skills.noSearchResults': 'No matching skills',
|
||||
'settings.skills.noSearchResultsHint': 'Try another keyword or clear search to show every skill.',
|
||||
'settings.skills.entryEyebrow': 'Skill Entry',
|
||||
'settings.skills.slashCommand': '/slash',
|
||||
'settings.skills.tokenEstimate': '~{count} tokens',
|
||||
|
||||
@ -581,6 +581,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.skills.browserTitle': '浏览已安装技能',
|
||||
'settings.skills.browserEyebrow': '技能浏览器',
|
||||
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
|
||||
'settings.skills.searchLabel': '搜索技能',
|
||||
'settings.skills.searchPlaceholder': '搜索技能名称、描述或来源...',
|
||||
'settings.skills.searchResultCount': '匹配 {count} / {total} 个技能',
|
||||
'settings.skills.clearSearch': '清空技能搜索',
|
||||
'settings.skills.noSearchResults': '没有匹配的技能',
|
||||
'settings.skills.noSearchResultsHint': '换一个关键词,或清空搜索查看全部技能。',
|
||||
'settings.skills.entryEyebrow': '技能入口',
|
||||
'settings.skills.slashCommand': '/斜杠命令',
|
||||
'settings.skills.tokenEstimate': '约 {count} tokens',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user