feat(skills): add Skills tab UI with list, file tree, and content viewer

- SkillList: grouped display by source with icons and token estimates
- SkillDetail: two-panel layout with file tree navigation + Markdown/code preview
- Settings: integrated Skills tab with auto_awesome icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 11:32:47 +08:00
parent 039ad8afd7
commit a06f6bf78f
3 changed files with 376 additions and 1 deletions

View File

@ -0,0 +1,221 @@
import { useState } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { CodeViewer } from '../chat/CodeViewer'
import type { FileTreeNode } from '../../types/skill'
// ─── Main Component ──────────────────────────────────────────────────────────
export function SkillDetail() {
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
const t = useTranslation()
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
if (isDetailLoading) {
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 (!selectedSkill) return null
const { meta, tree, files } = selectedSkill
const currentFile = files.find((f) => f.path === selectedFile) || files[0]
return (
<div className="flex flex-col h-full">
{/* Back button */}
<div className="mb-3">
<button
onClick={clearSelection}
className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
>
<span className="material-symbols-outlined text-[16px]">
arrow_back
</span>
{t('settings.skills.back')}
</button>
</div>
{/* Skill header */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">
{meta.displayName || meta.name}
</h3>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none uppercase">
{meta.source}
</span>
</div>
<p className="text-sm text-[var(--color-text-tertiary)]">
{meta.description}
</p>
<div className="flex gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
<span>~{Math.ceil(meta.contentLength / 4)} tokens</span>
<span>
{files.length} {t('settings.skills.files')}
</span>
{meta.version && <span>v{meta.version}</span>}
</div>
</div>
{/* Two-panel: file tree + content viewer */}
<div className="flex flex-1 min-h-0 border border-[var(--color-border)] rounded-xl overflow-hidden">
{/* Left: File tree */}
<div className="w-[200px] border-r border-[var(--color-border)] overflow-y-auto bg-[var(--color-surface-container-low)] py-1">
<TreeView
nodes={tree}
selectedPath={selectedFile}
onSelect={setSelectedFile}
depth={0}
/>
</div>
{/* Right: File content */}
<div className="flex-1 overflow-y-auto">
{currentFile && (
<div className="flex flex-col">
{/* File path header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<span className="text-xs text-[var(--color-text-tertiary)] font-mono">
{currentFile.path}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] uppercase tracking-wider">
{currentFile.language}
</span>
</div>
{/* Content */}
<div className="p-4">
{currentFile.language === 'markdown' ? (
<MarkdownRenderer content={currentFile.content} />
) : (
<CodeViewer
code={currentFile.content}
language={currentFile.language}
maxLines={9999}
showLineNumbers
/>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
// ─── File Tree Components ────────────────────────────────────────────────────
function TreeView({
nodes,
selectedPath,
onSelect,
depth,
}: {
nodes: FileTreeNode[]
selectedPath: string
onSelect: (path: string) => void
depth: number
}) {
return (
<>
{nodes.map((node) => (
<TreeItem
key={node.path}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth}
/>
))}
</>
)
}
function TreeItem({
node,
selectedPath,
onSelect,
depth,
}: {
node: FileTreeNode
selectedPath: string
onSelect: (path: string) => void
depth: number
}) {
const [expanded, setExpanded] = useState(true)
const isSelected = node.path === selectedPath
const isDir = node.type === 'directory'
const icon = isDir
? expanded
? 'folder_open'
: 'folder'
: fileIcon(node.name)
return (
<div>
<button
onClick={() => (isDir ? setExpanded(!expanded) : onSelect(node.path))}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors ${
isSelected
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
>
{isDir ? (
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
{expanded ? 'expand_more' : 'chevron_right'}
</span>
) : (
<span style={{ width: 12 }} />
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
{icon}
</span>
<span className="truncate">{node.name}</span>
</button>
{isDir && expanded && node.children && (
<TreeView
nodes={node.children}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth + 1}
/>
)}
</div>
)
}
function fileIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
switch (ext) {
case 'md':
return 'description'
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
case 'py':
case 'rs':
case 'go':
return 'code'
case 'json':
case 'yaml':
case 'yml':
case 'toml':
return 'data_object'
case 'sh':
case 'bash':
return 'terminal'
default:
return 'draft'
}
}

View File

@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { useSkillStore } from '../../stores/skillStore'
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',
}
export function SkillList() {
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
useSkillStore()
const t = useTranslation()
useEffect(() => {
fetchSkills()
}, [fetchSkills])
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">
<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>
)
}
// Group by source
const grouped: Partial<Record<SkillSource, SkillMeta[]>> = {}
for (const skill of skills) {
const src = skill.source as SkillSource
;(grouped[src] ??= []).push(skill)
}
return (
<div className="flex flex-col gap-4">
{SOURCE_ORDER.map((source) => {
const group = grouped[source]
if (!group?.length) return null
return (
<div key={source}>
{/* Group header */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">
{SOURCE_ICONS[source]}
</span>
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">
{t(`settings.skills.source.${source}`)}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
({group.length})
</span>
</div>
{/* Skill items */}
<div className="flex flex-col gap-1">
{group.map((skill) => (
<button
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name)
}
disabled={!skill.hasDirectory}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] transition-all text-left group disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-[var(--color-border)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
auto_awesome
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{skill.displayName || skill.name}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{skill.description}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-[var(--color-text-tertiary)]">
~{Math.ceil(skill.contentLength / 4)} tokens
</span>
{skill.hasDirectory && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
chevron_right
</span>
)}
</div>
</button>
))}
</div>
</div>
)
})}
</div>
)
}

View File

@ -11,8 +11,11 @@ import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
import { AdapterSettings } from './AdapterSettings'
import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -27,6 +30,7 @@ export function Settings() {
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
</div>
{/* Tab content */}
@ -35,6 +39,7 @@ export function Settings() {
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'skills' && <SkillSettings />}
</div>
</div>
</div>
@ -603,3 +608,30 @@ function GeneralSettings() {
</div>
)
}
// ─── Skill Settings ──────────────────────────────────────
function SkillSettings() {
const selectedSkill = useSkillStore((s) => s.selectedSkill)
const t = useTranslation()
if (selectedSkill) {
return (
<div className="max-w-3xl">
<SkillDetail />
</div>
)
}
return (
<div className="max-w-2xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.skills.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.skills.description')}
</p>
<SkillList />
</div>
)
}