diff --git a/backend/apps/skills/models.py b/backend/apps/skills/models.py index 0770b0cb..c60af632 100644 --- a/backend/apps/skills/models.py +++ b/backend/apps/skills/models.py @@ -21,6 +21,8 @@ class Skill(BaseModel): version: str = "" # The detail-page toggle: a disabled skill stays installed but leaves the agent's skill list, the Skill tool refuses to load it, and cloud runs skip it. enabled: bool = True + # SKILL.md mtime (epoch seconds); feeds the settings table's Last updated column. + updated_at: float = 0 class SkillCreate(BaseModel): diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py index 42ef4532..878252fe 100644 --- a/backend/apps/skills/skills.py +++ b/backend/apps/skills/skills.py @@ -248,9 +248,17 @@ def p_build_skill(skill_id: str, content: str, md_path: str, kind: str, index: d folder=meta.get("folder", ""), version=meta.get("version", ""), enabled=bool(meta.get("enabled", True)), + updated_at=p_mtime(md_path), ) +def p_mtime(path: str) -> float: + try: + return os.path.getmtime(path) + except OSError: + return 0 + + def sync_skills() -> list[Skill]: """Sync skills from the filesystem, updating the index. Reads both layouts: legacy flat .md files and multi-file /SKILL.md folders.""" diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 568f0f14..c6bbb4ac 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -26,10 +26,8 @@ import TerminalIcon from '@mui/icons-material/Terminal'; import DescriptionIcon from '@mui/icons-material/Description'; import SearchIcon from '@mui/icons-material/Search'; import DownloadIcon from '@mui/icons-material/Download'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import FolderIcon from '@mui/icons-material/Folder'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import CodeIcon from '@mui/icons-material/Code'; import VisibilityIcon from '@mui/icons-material/Visibility'; @@ -45,16 +43,7 @@ import { deleteSkill, Skill, } from '@/shared/state/skillsSlice'; -import { - fetchAllRegistrySkills, - fetchSkillRegistryStats, - fetchSkillDetail, - fetchSkillUpdates, - installCuratedSkill, - updateInstalledSkill, - RegistrySkill, - RegistrySkillDetail, -} from '@/shared/state/skillRegistrySlice'; +import { fetchSkillUpdates, updateInstalledSkill } from '@/shared/state/skillRegistrySlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; import { requestShare } from '@/app/components/share/ShareRequestHost'; import { API_BASE } from '@/shared/config'; @@ -62,7 +51,7 @@ import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat'; import DirectoryDialog from '../Directory/DirectoryDialog'; -import UploadSkillDialog from '../Directory/UploadSkillDialog'; +import UploadSkillDialog from '../Directory/dialogs/UploadSkillDialog'; import DriveFolderUploadOutlinedIcon from '@mui/icons-material/DriveFolderUploadOutlined'; interface SkillForm { @@ -73,32 +62,21 @@ interface SkillForm { } type Selection = - | { type: 'registry'; name: string } | { type: 'local'; id: string } | { type: 'builder-preview' } | null; const emptyForm: SkillForm = { name: '', description: '', content: '', command: '' }; -const SIDEBAR_W = 260; - const Skills: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const { items, loading } = useAppSelector((s) => s.skills); - const { - skills: regSkills, - loading: regLoading, - stats: regStats, - detail: regDetail, - detailLoading: regDetailLoading, - outdated: regOutdated, - } = useAppSelector((s) => s.skillRegistry); + const regOutdated = useAppSelector((s) => s.skillRegistry.outdated); const localSkills = Object.values(items); const [selection, setSelection] = useState(null); const [searchFilter, setSearchFilter] = useState(''); - const [collapsedCats, setCollapsedCats] = useState>({}); const [contentView, setContentView] = useState<'preview' | 'raw'>('preview'); const [dialogOpen, setDialogOpen] = useState(false); @@ -128,47 +106,21 @@ const Skills: React.FC = () => { useEffect(() => { dispatch(fetchSkills()); - dispatch(fetchSkillRegistryStats()); - dispatch(fetchAllRegistrySkills()); dispatch(fetchSkillUpdates()); }, [dispatch]); - const regGrouped = useMemo(() => { - const groups: Record = {}; - const q = searchFilter.trim().toLowerCase(); - for (const sk of regSkills) { - if (q && !sk.name.toLowerCase().includes(q) && !sk.description.toLowerCase().includes(q)) continue; - const cat = sk.category || 'General'; - if (!groups[cat]) groups[cat] = []; - groups[cat].push(sk); - } - return groups; - }, [regSkills, searchFilter]); - const filteredLocal = useMemo(() => { const q = searchFilter.trim().toLowerCase(); if (!q) return localSkills; return localSkills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)); }, [localSkills, searchFilter]); - const categoryOrder = useMemo(() => Object.keys(regGrouped).sort(), [regGrouped]); - - const toggleCategory = (cat: string) => - setCollapsedCats((p) => ({ ...p, [cat]: !p[cat] })); - - const selectRegistry = (name: string) => { - setSelection({ type: 'registry', name }); - dispatch(fetchSkillDetail(name)); - }; - const selectLocal = (id: string) => { setSelection({ type: 'local', id }); }; const selectedLocal: Skill | null = selection?.type === 'local' ? items[selection.id] ?? null : null; - const selectedReg: RegistrySkillDetail | null = - selection?.type === 'registry' && regDetail?.name === selection.name ? regDetail : null; const openCreate = () => { setEditingId(null); @@ -196,21 +148,6 @@ const Skills: React.FC = () => { if (selection?.type === 'local' && selection.id === id) setSelection(null); }; - const handleInstall = async () => { - if (!selectedReg) return; - try { - await dispatch(installCuratedSkill(selectedReg.folder)).unwrap(); - } catch (e) { - // unwrap() rejects with a plain serialized object, not an Error instance, so read .message off it directly. - const msg = (e as { message?: string })?.message || 'unknown error'; - setSnackbar({ open: true, message: `Install failed: ${msg}` }); - return; - } - await dispatch(fetchSkills()); - onboardingBus.emit('skill:installed'); - setSnackbar({ open: true, message: `Installed "${selectedReg.name}" as a local skill` }); - }; - const [updatingId, setUpdatingId] = useState(null); const handleUpdate = async (skill: Skill) => { setUpdatingId(skill.id); @@ -231,24 +168,6 @@ const Skills: React.FC = () => { setSnackbar({ open: true, message: `Updated "${skill.name}" to the latest version${flagged}` }); }; - const handleEditInstall = () => { - if (!selectedReg) return; - setEditingId(null); - setForm({ - name: selectedReg.name, - description: selectedReg.description, - content: selectedReg.content, - command: selectedReg.name.toLowerCase().replace(/\s+/g, '-'), - }); - setDialogOpen(true); - }; - - const isSelected = (type: 'registry' | 'local', key: string) => { - if (!selection) return false; - if (type === 'registry') return selection.type === 'registry' && selection.name === key; - return selection.type === 'local' && selection.id === key; - }; - // claude.ai's content card header: [SKILL.md v] file picker + "N files" + eye/code toggles. Single-file skills hide the picker. const ContentPreview: React.FC<{ content: string; skillId?: string; multiFile?: boolean }> = ({ content, skillId, multiFile }) => { const [files, setFiles] = useState<{ path: string; content: string }[]>([]); @@ -375,46 +294,16 @@ const Skills: React.FC = () => { ); }; - const SidebarRow: React.FC<{ - label: string; - selected: boolean; - onClick: () => void; - icon?: React.ReactNode; - onboardingId?: string; - trailing?: React.ReactNode; - }> = ({ label, selected, onClick, icon, onboardingId, trailing }) => ( - - {icon ?? } - - {label} - - {trailing && {trailing}} - - ); + const fmtUpdated = (epoch?: number): string => + epoch ? new Date(epoch * 1000).toLocaleDateString('en-US', { month: 'numeric', day: 'numeric', year: '2-digit' }) : ''; + const authorOf = (sk: Skill): string => + sk.built_in ? 'OpenSwarm' : /anthropic/i.test(sk.source || '') ? 'Anthropic' : sk.source ? sk.source.split('/')[0] : 'You'; + const detailOpen = (selection?.type === 'builder-preview' && !!builderPreview) || !!selectedLocal; return ( - - + + {!detailOpen ? ( + <> {/* claude.ai's Skills header grammar: search icon, Browse, Add menu (Create with Claude / Write skill instructions / Upload a skill), plus our Import .swarm row. */} @@ -499,103 +388,61 @@ const Skills: React.FC = () => { /> - - - {filteredLocal.length > 0 && ( - - toggleCategory('__local')} - sx={{ - display: 'flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.5, - cursor: 'pointer', userSelect: 'none', - '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, borderRadius: `${c.radius.sm}px`, - }} - > - {collapsedCats['__local'] - ? - : } - - My Skills - - ({filteredLocal.length}) - - - - {filteredLocal.map((sk) => ( - selectLocal(sk.id)} - icon={} - trailing={regOutdated.includes(sk.id) - ? - : undefined} - /> - ))} - - - - )} - - {(loading || regLoading) && regSkills.length === 0 && localSkills.length === 0 ? ( + {/* claude.ai's Skills settings body: a clean table of INSTALLED skills; browsing lives in the Directory. */} + + + Skill + Last updated + Author + + {loading && localSkills.length === 0 ? ( - ) : ( - categoryOrder.map((cat) => { - const group = regGrouped[cat]; - if (!group || group.length === 0) return null; - const isCollapsed = !!collapsedCats[cat]; - return ( - - toggleCategory(cat)} - sx={{ - display: 'flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.5, - cursor: 'pointer', userSelect: 'none', - '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, borderRadius: `${c.radius.sm}px`, - }} - > - {isCollapsed - ? - : } - - {cat} - - ({group.length}) - - - - {group.map((sk) => ( - selectRegistry(sk.name)} - onboardingId={ - /pdf/i.test(sk.name) ? 'skill-item-pdf' : undefined - } - /> - ))} - - - - ); - }) - )} + ) : filteredLocal.length === 0 ? ( + + + No skills yet. Browse the directory or add your own. + + ) : filteredLocal.map((sk) => ( + selectLocal(sk.id)} + sx={{ + display: 'grid', gridTemplateColumns: '1fr 120px 120px', alignItems: 'center', + px: 1, py: 1.4, borderBottom: `1px solid ${c.border.subtle}`, cursor: 'pointer', + '&:hover': { bgcolor: c.bg.secondary }, + }} + > + + {sk.name} + {sk.enabled === false && ( + Disabled + )} + {regOutdated.includes(sk.id) && ( + + )} + + {fmtUpdated(sk.updated_at)} + {authorOf(sk)} + + ))} - - - - {selection?.type === 'builder-preview' && builderPreview ? ( - + + ) : ( + + + setSelection(null)} + sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, cursor: 'pointer', color: c.text.secondary, '&:hover': { color: c.text.primary } }} + > + + Skills + + + {selection?.type === 'builder-preview' && builderPreview ? ( + @@ -645,81 +492,8 @@ const Skills: React.FC = () => { - ) : !selection ? ( - - - Select a skill to view its details - - ) : selection.type === 'registry' ? ( - regDetailLoading && !selectedReg ? ( - - - - ) : selectedReg ? ( + ) : selectedLocal ? ( - - - {selectedReg.name} - - - - - {selectedReg.repositoryUrl && ( - - - - - - )} - - - - - Added by Anthropic - - - - - {selectedReg.description} - - - - - - ) : null - ) : selectedLocal ? ( - {/* claude.ai detail chrome: title + info, byline underneath, enable toggle + kebab on the right. */} @@ -795,8 +569,9 @@ const Skills: React.FC = () => { - ) : null} - + ) : null} + + )}