From a06f6bf78fcddbb62b99c33510713561727c3583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Apr 2026 11:32:47 +0800 Subject: [PATCH] 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) --- desktop/src/components/skills/SkillDetail.tsx | 221 ++++++++++++++++++ desktop/src/components/skills/SkillList.tsx | 122 ++++++++++ desktop/src/pages/Settings.tsx | 34 ++- 3 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 desktop/src/components/skills/SkillDetail.tsx create mode 100644 desktop/src/components/skills/SkillList.tsx diff --git a/desktop/src/components/skills/SkillDetail.tsx b/desktop/src/components/skills/SkillDetail.tsx new file mode 100644 index 00000000..142ab8cc --- /dev/null +++ b/desktop/src/components/skills/SkillDetail.tsx @@ -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('SKILL.md') + + if (isDetailLoading) { + return ( +
+
+
+ ) + } + + if (!selectedSkill) return null + + const { meta, tree, files } = selectedSkill + const currentFile = files.find((f) => f.path === selectedFile) || files[0] + + return ( +
+ {/* Back button */} +
+ +
+ + {/* Skill header */} +
+
+

+ {meta.displayName || meta.name} +

+ + {meta.source} + +
+

+ {meta.description} +

+
+ ~{Math.ceil(meta.contentLength / 4)} tokens + + {files.length} {t('settings.skills.files')} + + {meta.version && v{meta.version}} +
+
+ + {/* Two-panel: file tree + content viewer */} +
+ {/* Left: File tree */} +
+ +
+ + {/* Right: File content */} +
+ {currentFile && ( +
+ {/* File path header */} +
+ + {currentFile.path} + + + {currentFile.language} + +
+ + {/* Content */} +
+ {currentFile.language === 'markdown' ? ( + + ) : ( + + )} +
+
+ )} +
+
+
+ ) +} + +// ─── File Tree Components ──────────────────────────────────────────────────── + +function TreeView({ + nodes, + selectedPath, + onSelect, + depth, +}: { + nodes: FileTreeNode[] + selectedPath: string + onSelect: (path: string) => void + depth: number +}) { + return ( + <> + {nodes.map((node) => ( + + ))} + + ) +} + +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 ( +
+ + + {isDir && expanded && node.children && ( + + )} +
+ ) +} + +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' + } +} diff --git a/desktop/src/components/skills/SkillList.tsx b/desktop/src/components/skills/SkillList.tsx new file mode 100644 index 00000000..b340fbdc --- /dev/null +++ b/desktop/src/components/skills/SkillList.tsx @@ -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 = { + 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 ( +
+
+
+ ) + } + + if (error) { + return
{error}
+ } + + if (skills.length === 0) { + return ( +
+ + auto_awesome + +

+ {t('settings.skills.empty')} +

+

+ {t('settings.skills.emptyHint')} +

+
+ ) + } + + // Group by source + const grouped: Partial> = {} + for (const skill of skills) { + const src = skill.source as SkillSource + ;(grouped[src] ??= []).push(skill) + } + + return ( +
+ {SOURCE_ORDER.map((source) => { + const group = grouped[source] + if (!group?.length) return null + + return ( +
+ {/* Group header */} +
+ + {SOURCE_ICONS[source]} + + + {t(`settings.skills.source.${source}`)} + + + ({group.length}) + +
+ + {/* Skill items */} +
+ {group.map((skill) => ( + + ))} +
+
+ ) + })} +
+ ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2d225b68..73a0a216 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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('providers') @@ -27,6 +30,7 @@ export function Settings() { setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('adapters')} /> + setActiveTab('skills')} />
{/* Tab content */} @@ -35,6 +39,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'general' && } {activeTab === 'adapters' && } + {activeTab === 'skills' && }
@@ -603,3 +608,30 @@ function GeneralSettings() { ) } + +// ─── Skill Settings ────────────────────────────────────── + +function SkillSettings() { + const selectedSkill = useSkillStore((s) => s.selectedSkill) + const t = useTranslation() + + if (selectedSkill) { + return ( +
+ +
+ ) + } + + return ( +
+

+ {t('settings.skills.title')} +

+

+ {t('settings.skills.description')} +

+ +
+ ) +}