From 591138862654530e5dfa129bdbcaccf1b84f9f61 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 14:59:33 +0800 Subject: [PATCH] fix(skills): polish desktop browser layout and rendering Improve the desktop Skills browser so SKILL.md metadata renders cleanly and the settings view uses space like a real document browser. Add coverage for the new detail, markdown, and i18n behavior. Co-Authored-By: Claude Opus 4.6 --- desktop/src/__tests__/agentsSettings.test.tsx | 135 ++++++-- desktop/src/__tests__/skillsSettings.test.tsx | 159 +++++++++ .../markdown/MarkdownRenderer.test.tsx | 39 +++ .../components/markdown/MarkdownRenderer.tsx | 60 +++- .../components/skills/SkillDetail.test.tsx | 89 +++++ desktop/src/components/skills/SkillDetail.tsx | 320 ++++++++++++++---- desktop/src/components/skills/SkillList.tsx | 248 ++++++++++---- desktop/src/i18n/locales/en.ts | 23 ++ desktop/src/i18n/locales/zh.ts | 23 ++ desktop/src/pages/Settings.tsx | 4 +- desktop/src/types/skill.ts | 5 + src/server/api/skills.ts | 46 ++- 12 files changed, 963 insertions(+), 188 deletions(-) create mode 100644 desktop/src/__tests__/skillsSettings.test.tsx create mode 100644 desktop/src/components/markdown/MarkdownRenderer.test.tsx create mode 100644 desktop/src/components/skills/SkillDetail.test.tsx diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index c353c789..a748ae5f 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -4,6 +4,8 @@ import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useAgentStore } from '../stores/agentStore' +import { useSkillStore } from '../stores/skillStore' +import { useSettingsStore } from '../stores/settingsStore' // Mock the API module so no real HTTP calls are made vi.mock('../api/agents', () => ({ @@ -12,17 +14,8 @@ vi.mock('../api/agents', () => ({ }, })) -/** Override fetchAgents to a no-op so useEffect doesn't clobber manually set state */ const noopFetch = vi.fn() -// Mock MarkdownRenderer to avoid pulling in `marked` and CodeViewer deps -vi.mock('../components/markdown/MarkdownRenderer', () => ({ - MarkdownRenderer: ({ content }: { content: string }) => ( -
{content}
- ), -})) - -// Mock the provider store so ProviderSettings doesn't crash vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], @@ -39,11 +32,14 @@ vi.mock('../stores/providerStore', () => ({ }), })) -// Mock the adapter settings to avoid its side effects vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) +vi.mock('../components/chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + const MOCK_AGENTS = [ { name: 'code-reviewer', @@ -69,20 +65,69 @@ const MOCK_AGENTS = [ }, ] +const MOCK_SKILL_DETAIL = { + meta: { + name: 'skill-docs', + displayName: 'Skill Docs', + description: 'A rich skill readme', + source: 'user' as const, + userInvocable: true, + contentLength: 200, + hasDirectory: true, + }, + tree: [ + { name: 'SKILL.md', path: 'SKILL.md', type: 'file' as const }, + { name: 'helper.ts', path: 'helper.ts', type: 'file' as const }, + ], + files: [ + { + path: 'SKILL.md', + language: 'markdown', + content: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote', + body: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote', + isEntry: true, + frontmatter: { + description: 'A rich skill readme', + model: 'sonnet', + }, + }, + { + path: 'helper.ts', + language: 'typescript', + content: 'export const helper = true', + isEntry: false, + }, + ], + skillRoot: '/tmp/skill-docs', +} + function switchToAgentsTab() { - // The Agents tab button has text "Agents" - const agentsTab = screen.getByText('Agents') - fireEvent.click(agentsTab) + fireEvent.click(screen.getByText('Agents')) +} + +function switchToSkillsTab() { + fireEvent.click(screen.getByText('Skills')) } describe('Settings > Agents tab', () => { beforeEach(() => { - // Reset store to default state before each test + useSettingsStore.setState({ locale: 'en' }) useAgentStore.setState({ agents: [], isLoading: false, error: null, selectedAgent: null, + fetchAgents: noopFetch, + }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: noopFetch, + fetchSkillDetail: noopFetch, + clearSelection: () => useSkillStore.setState({ selectedSkill: null }), }) }) @@ -128,7 +173,6 @@ describe('Settings > Agents tab', () => { expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument() expect(screen.getByText('doc-writer')).toBeInTheDocument() expect(screen.getByText('Writes technical documentation')).toBeInTheDocument() - // Agent count badge expect(screen.getByText('3 agents')).toBeInTheDocument() }) @@ -156,12 +200,9 @@ describe('Settings > Agents tab', () => { fireEvent.click(screen.getByText('code-reviewer')) - // Detail view should show expect(screen.getByText('Back to list')).toBeInTheDocument() expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument() - // Model meta expect(screen.getByText(/claude-sonnet-4-6/)).toBeInTheDocument() - // Tools meta expect(screen.getByText(/Read, Grep, Glob/)).toBeInTheDocument() }) @@ -172,9 +213,8 @@ describe('Settings > Agents tab', () => { fireEvent.click(screen.getByText('code-reviewer')) - const mdRenderer = screen.getByTestId('markdown-renderer') - expect(mdRenderer).toBeInTheDocument() - expect(mdRenderer.textContent).toContain('Code Reviewer') + expect(screen.getByRole('heading', { name: 'Code Reviewer' })).toBeInTheDocument() + expect(screen.getByText('You are an expert code reviewer.')).toBeInTheDocument() }) it('shows "no system prompt" message when agent has no prompt', () => { @@ -192,16 +232,63 @@ describe('Settings > Agents tab', () => { render() switchToAgentsTab() - // Go to detail fireEvent.click(screen.getByText('code-reviewer')) expect(screen.getByText('Back to list')).toBeInTheDocument() - // Go back fireEvent.click(screen.getByText('Back to list')) - // Should see the list again expect(screen.getByText('code-reviewer')).toBeInTheDocument() expect(screen.getByText('doc-writer')).toBeInTheDocument() expect(screen.getByText('plain-agent')).toBeInTheDocument() }) }) + +describe('Settings > Skills tab', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: noopFetch, + fetchSkillDetail: noopFetch, + clearSelection: () => useSkillStore.setState({ selectedSkill: null }), + }) + }) + + it('renders markdown skills with document styling in detail view', () => { + useSkillStore.setState({ + selectedSkill: MOCK_SKILL_DETAIL, + clearSelection: () => useSkillStore.setState({ selectedSkill: null }), + }) + + render() + switchToSkillsTab() + + expect(screen.getByText('Skill metadata')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument() + + const rendererRoot = screen.getByRole('heading', { name: 'Heading' }).closest('div[class*="prose"]') + expect(rendererRoot?.className).toContain('max-w-[72ch]') + expect(rendererRoot?.className).toContain('prose-h2:border-b') + expect(rendererRoot?.className).toContain('prose-p:text-[15px]') + expect(screen.getByText('Helpful quote')).toBeInTheDocument() + }) + + it('keeps code files rendered in CodeViewer instead of markdown prose', () => { + useSkillStore.setState({ + selectedSkill: MOCK_SKILL_DETAIL, + clearSelection: () => useSkillStore.setState({ selectedSkill: null }), + }) + + render() + switchToSkillsTab() + + fireEvent.click(screen.getAllByText('helper.ts')[0]!) + + expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const helper = true') + expect(screen.queryByRole('heading', { name: 'Heading' })).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx new file mode 100644 index 00000000..ca0a3801 --- /dev/null +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { useSkillStore } from '../stores/skillStore' +import { useSettingsStore } from '../stores/settingsStore' + +vi.mock('../api/agents', () => ({ + agentsApi: { + list: vi.fn().mockResolvedValue({ agents: [] }), + }, +})) + +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + isLoading: false, + fetchProviders: vi.fn(), + deleteProvider: vi.fn(), + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), + }), +})) + +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
Adapter Settings Mock
, +})) + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: () => ({ + agents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: vi.fn(), + selectAgent: vi.fn(), + }), +})) + +vi.mock('../components/chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + +const MOCK_FETCH_SKILLS = vi.fn() +const MOCK_FETCH_SKILL_DETAIL = vi.fn() +const MOCK_CLEAR_SELECTION = vi.fn() + +function switchToSkillsTab() { + fireEvent.click(screen.getByText('Skills')) +} + +describe('Settings > Skills tab', () => { + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: MOCK_FETCH_SKILLS, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: MOCK_CLEAR_SELECTION, + }) + }) + + it('renders browser summary and grouped skill cards', () => { + useSkillStore.setState({ + skills: [ + { + name: 'alpha', + displayName: 'Alpha Skill', + description: 'First skill description', + source: 'user', + userInvocable: true, + version: '1.0.0', + contentLength: 400, + hasDirectory: true, + }, + { + name: 'beta', + description: 'Second skill description', + source: 'project', + userInvocable: false, + contentLength: 200, + hasDirectory: true, + }, + ], + }) + + render() + switchToSkillsTab() + + expect(screen.getByText('Browse installed skills')).toBeInTheDocument() + expect(screen.getByText('Skill Browser')).toBeInTheDocument() + expect(screen.getByText('Total skills')).toBeInTheDocument() + expect(screen.getByText('Alpha Skill')).toBeInTheDocument() + expect(screen.getByText('Second skill description')).toBeInTheDocument() + }) + + it('opens skill detail with metadata cards and parsed markdown body', () => { + useSkillStore.setState({ + selectedSkill: { + meta: { + name: 'alpha', + displayName: 'Alpha Skill', + description: 'First skill description', + source: 'user', + userInvocable: true, + version: '1.0.0', + contentLength: 400, + hasDirectory: true, + }, + tree: [ + { name: 'SKILL.md', path: 'SKILL.md', type: 'file' }, + { name: 'run.ts', path: 'run.ts', type: 'file' }, + ], + files: [ + { + path: 'SKILL.md', + content: '# Hello\n\nBody content', + body: '# Hello\n\nBody content', + language: 'markdown', + isEntry: true, + frontmatter: { + description: 'Frontmatter description', + 'allowed-tools': ['Read', 'Edit'], + model: 'sonnet', + }, + }, + { + path: 'run.ts', + content: 'console.log("hello")', + language: 'typescript', + isEntry: false, + }, + ], + skillRoot: '/tmp/alpha', + }, + }) + + render() + switchToSkillsTab() + + expect(screen.getByText('Skill metadata')).toBeInTheDocument() + expect(screen.getByText('/slash')).toBeInTheDocument() + expect(screen.getByText('Frontmatter description')).toBeInTheDocument() + expect(screen.getByText('Read, Edit')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx new file mode 100644 index 00000000..bdb8090e --- /dev/null +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { MarkdownRenderer } from './MarkdownRenderer' + +describe('MarkdownRenderer', () => { + it('applies document prose classes and custom width classes', () => { + const { container } = render( + , + ) + + const root = container.firstChild as HTMLDivElement + expect(root).toBeInTheDocument() + expect(root.className).toContain('prose-p:text-[15px]') + expect(root.className).toContain('prose-h2:border-b') + expect(root.className).toContain('mx-auto') + expect(root.className).toContain('max-w-[72ch]') + expect(screen.getByText('Skill Title')).toBeInTheDocument() + expect(screen.getByText('Readable paragraph text.')).toBeInTheDocument() + }) + + it('keeps default variant free of document-only typography classes', () => { + const { container } = render( + , + ) + + const root = container.firstChild as HTMLDivElement + expect(root).toBeInTheDocument() + expect(root.className).not.toContain('prose-p:text-[15px]') + expect(root.className).not.toContain('prose-h2:border-b') + expect(screen.getByText('Default Heading')).toBeInTheDocument() + expect(screen.getByText('Body copy.')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index fd26e457..3b752f45 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -4,6 +4,8 @@ import { CodeViewer } from '../chat/CodeViewer' type Props = { content: string + variant?: 'default' | 'document' + className?: string } type CodeBlock = { @@ -36,8 +38,50 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] return { html, codeBlocks } } -export function MarkdownRenderer({ content }: Props) { +const BASE_PROSE_CLASSES = `prose prose-sm max-w-none text-[var(--color-text-primary)] + prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold + prose-p:my-2 prose-p:leading-relaxed + prose-p:break-words + prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded + prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none + prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline + prose-strong:text-[var(--color-text-primary)] + prose-ul:my-2 prose-ol:my-2 + prose-li:my-0.5 + prose-table:w-full prose-table:table-auto prose-table:text-sm + prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top + prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top` + +const DOCUMENT_PROSE_CLASSES = ` + prose-p:text-[15px] prose-p:leading-7 + prose-headings:scroll-mt-6 prose-headings:tracking-[-0.01em] + prose-h1:mb-4 prose-h1:text-2xl prose-h1:font-semibold prose-h1:leading-tight + prose-h2:mt-8 prose-h2:mb-3 prose-h2:border-b prose-h2:border-[var(--color-border)] prose-h2:pb-2 prose-h2:text-xl prose-h2:font-semibold + prose-h3:mt-6 prose-h3:mb-2 prose-h3:text-base prose-h3:font-semibold + prose-h4:mt-5 prose-h4:mb-2 prose-h4:text-sm prose-h4:font-semibold + prose-blockquote:my-4 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic + prose-hr:my-6 prose-hr:border-[var(--color-border)] + prose-img:rounded-lg prose-img:border prose-img:border-[var(--color-border)] + prose-kbd:rounded prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-[var(--font-mono)] prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none + prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)] + prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)] + prose-li:my-1.5 + prose-table:my-5 + prose-th:text-left + prose-td:bg-[var(--color-surface)]` + +function getProseClasses(variant: 'default' | 'document', className?: string) { + return [BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', className ?? ''] + .filter(Boolean) + .join(' ') +} + +export function MarkdownRenderer({ content, variant = 'default', className }: Props) { const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content]) + const proseClasses = useMemo( + () => getProseClasses(variant, className), + [variant, className], + ) const parts = useMemo(() => { if (codeBlocks.length === 0) { @@ -87,20 +131,6 @@ export function MarkdownRenderer({ content }: Props) { } }, []) - const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)] - prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold - prose-p:my-2 prose-p:leading-relaxed - prose-p:break-words - prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded - prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none - prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline - prose-strong:text-[var(--color-text-primary)] - prose-ul:my-2 prose-ol:my-2 - prose-li:my-0.5 - prose-table:w-full prose-table:table-auto prose-table:text-sm - prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top - prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top` - if (codeBlocks.length === 0) { return (
({ + MarkdownRenderer: ({ + content, + variant, + className, + }: { + content: string + variant?: string + className?: string + }) => ( +
+ ), +})) + +vi.mock('../chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + +import { SkillDetail } from './SkillDetail' +import { useSkillStore } from '../../stores/skillStore' +import { useSettingsStore } from '../../stores/settingsStore' + +const fetchSkills = vi.fn() +const fetchSkillDetail = vi.fn() +const clearSelection = vi.fn(() => { + useSkillStore.setState({ selectedSkill: null }) +}) + +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills, + fetchSkillDetail, + clearSelection, + }) + fetchSkills.mockReset() + fetchSkillDetail.mockReset() + clearSelection.mockClear() +}) + +describe('SkillDetail markdown presentation', () => { + it('renders markdown files with the document variant and readable width', () => { + useSkillStore.setState({ + selectedSkill: { + meta: { + name: 'skill-test', + displayName: 'Skill Test', + description: 'Skill description', + source: 'user', + userInvocable: true, + contentLength: 120, + hasDirectory: true, + }, + tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], + files: [ + { + path: 'SKILL.md', + content: '# Skill Body', + language: 'markdown', + isEntry: true, + }, + ], + skillRoot: '/tmp/skill-test', + }, + }) + + render() + + const markdown = screen.getByTestId('markdown-renderer') + expect(markdown).toBeInTheDocument() + expect(markdown).toHaveAttribute('data-variant', 'document') + expect(markdown).toHaveAttribute('data-classname', 'mx-auto max-w-[72ch]') + expect(markdown).toHaveAttribute('data-content', '# Skill Body') + }) +}) diff --git a/desktop/src/components/skills/SkillDetail.tsx b/desktop/src/components/skills/SkillDetail.tsx index 142ab8cc..d5b9cec0 100644 --- a/desktop/src/components/skills/SkillDetail.tsx +++ b/desktop/src/components/skills/SkillDetail.tsx @@ -1,17 +1,36 @@ -import { useState } from 'react' +import { useMemo, useState, type ReactNode } 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' +import type { FileTreeNode, SkillFrontmatter } from '../../types/skill' -// ─── Main Component ────────────────────────────────────────────────────────── +const META_PRIORITY = [ + 'description', + 'when_to_use', + 'argument-hint', + 'model', + 'effort', + 'allowed-tools', + 'paths', + 'agent', + 'context', + 'version', + 'user-invocable', +] as const export function SkillDetail() { const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore() const t = useTranslation() const [selectedFile, setSelectedFile] = useState('SKILL.md') + const normalizedSelection = useMemo(() => { + if (!selectedSkill) return 'SKILL.md' + return selectedSkill.files.some((file) => file.path === selectedFile) + ? selectedFile + : selectedSkill.files[0]?.path || 'SKILL.md' + }, [selectedFile, selectedSkill]) + if (isDetailLoading) { return (
@@ -23,75 +42,176 @@ export function SkillDetail() { if (!selectedSkill) return null const { meta, tree, files } = selectedSkill - const currentFile = files.find((f) => f.path === selectedFile) || files[0] + const currentFile = files.find((f) => f.path === normalizedSelection) || files[0] + const frontmatter = currentFile?.frontmatter + const metaEntries = getMetaEntries(frontmatter) 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}} -
-
+
+
+
+
+ {t('settings.skills.entryEyebrow')} +
+
+

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

+ {t(`settings.skills.source.${meta.source}`)} + {meta.version && v{meta.version}} + {meta.userInvocable && {t('settings.skills.slashCommand')}} +
+

+ {meta.description} +

+
+ {t('settings.skills.tokenEstimate', { count: String(Math.ceil(meta.contentLength / 4)) })} + + {files.length} {t('settings.skills.files')} + + {currentFile?.isEntry ? t('settings.skills.entryFile') : currentFile?.path} +
+
- {/* Two-panel: file tree + content viewer */} -
- {/* Left: File tree */} -
- +
+ + + + file.isEntry) ? 'SKILL.md' : '—'} + icon="article" + /> +
+
- {/* Right: File content */} -
- {currentFile && ( -
- {/* File path header */} -
- - {currentFile.path} - - - {currentFile.language} - + {metaEntries.length > 0 && ( +
+
+ + tune + +

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

+
+
+ {metaEntries.map(([key, value]) => ( +
+
+ {formatMetaKey(key)} +
+
+ {formatMetaValue(value)} +
+ ))} +
+
+ )} - {/* Content */} -
+
+ + +
+
+
+
+ + {currentFile?.path} + + {currentFile?.isEntry && {t('settings.skills.entryFile')}} +
+
+ {t('settings.skills.readingMode', { + mode: + currentFile?.language === 'markdown' + ? t('settings.skills.docMode') + : t('settings.skills.codeMode'), + })} +
+
+
+ + {currentFile?.language} + +
+
+ +
+
+ {files.map((file) => { + const active = file.path === normalizedSelection + return ( + + ) + })} +
+
+ +
+ {currentFile && ( +
{currentFile.language === 'markdown' ? ( - + ) : ( )}
-
- )} + )} +
-
+
) } -// ─── File Tree Components ──────────────────────────────────────────────────── - function TreeView({ nodes, selectedPath, @@ -152,22 +270,18 @@ function TreeItem({ const isSelected = node.path === selectedPath const isDir = node.type === 'directory' - const icon = isDir - ? expanded - ? 'folder_open' - : 'folder' - : fileIcon(node.name) + const icon = isDir ? (expanded ? 'folder_open' : 'folder') : fileIcon(node.name) return (
- ))} -
-
- ) - })} +
+ + ))} +
+ + ) + })} +
+
+ ) +} + +function SummaryCard({ + label, + value, + icon, + className = '', +}: { + label: string + value: string + icon: string + className?: string +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cd437939..c4b9ba13 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -157,10 +157,33 @@ export const en = { // Settings > Skills 'settings.skills.title': 'Installed Skills', 'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/', + '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.entryEyebrow': 'Skill Entry', + 'settings.skills.slashCommand': '/slash', + 'settings.skills.tokenEstimate': '~{count} tokens', + 'settings.skills.tokenEstimateShort': '~{count}', + 'settings.skills.summary.totalSkills': 'Total skills', + 'settings.skills.summary.totalFiles': 'Files', + 'settings.skills.summary.sources': 'Sources', + 'settings.skills.summary.tokens': 'Estimated tokens', + 'settings.skills.summary.source': 'Source', + 'settings.skills.summary.entry': 'Entry', + 'settings.skills.groupHint': '{count} skills available from {source}', + 'settings.skills.ready': 'Ready', + 'settings.skills.unavailable': 'Unavailable', 'settings.skills.empty': 'No skills installed', 'settings.skills.emptyHint': 'Add skills to ~/.claude/skills/ to get started', 'settings.skills.back': 'Back to list', 'settings.skills.files': 'files', + 'settings.skills.entryFile': 'entry file', + 'settings.skills.metaTitle': 'Skill metadata', + 'settings.skills.filesPanel': 'Files', + 'settings.skills.filesPanelHint': 'Browse the entry file and supporting implementation files.', + 'settings.skills.readingMode': 'Viewing in {mode}', + 'settings.skills.docMode': 'document mode', + 'settings.skills.codeMode': 'code mode', 'settings.skills.source.user': 'User', 'settings.skills.source.project': 'Project', 'settings.skills.source.plugin': 'Plugin', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f90b5801..df697ed8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -159,10 +159,33 @@ export const zh: Record = { // Settings > Skills 'settings.skills.title': '已安装技能', 'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。', + 'settings.skills.browserTitle': '浏览已安装技能', + 'settings.skills.browserEyebrow': '技能浏览器', + 'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。', + 'settings.skills.entryEyebrow': '技能入口', + 'settings.skills.slashCommand': '/斜杠命令', + 'settings.skills.tokenEstimate': '约 {count} tokens', + 'settings.skills.tokenEstimateShort': '约 {count}', + 'settings.skills.summary.totalSkills': '技能总数', + 'settings.skills.summary.totalFiles': '文件数', + 'settings.skills.summary.sources': '来源类型', + 'settings.skills.summary.tokens': '预估 tokens', + 'settings.skills.summary.source': '来源', + 'settings.skills.summary.entry': '入口文件', + 'settings.skills.groupHint': '{source}中有 {count} 个技能可用', + 'settings.skills.ready': '可查看', + 'settings.skills.unavailable': '不可用', 'settings.skills.empty': '暂无已安装技能', 'settings.skills.emptyHint': '在 ~/.claude/skills/ 中添加技能即可开始', 'settings.skills.back': '返回列表', 'settings.skills.files': '个文件', + 'settings.skills.entryFile': '入口文件', + 'settings.skills.metaTitle': '技能元数据', + 'settings.skills.filesPanel': '文件', + 'settings.skills.filesPanelHint': '浏览入口文件及其配套实现文件。', + 'settings.skills.readingMode': '当前为{mode}', + 'settings.skills.docMode': '文档模式', + 'settings.skills.codeMode': '代码模式', 'settings.skills.source.user': '用户', 'settings.skills.source.project': '项目', 'settings.skills.source.plugin': '插件', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 05cc39ac..7d022db0 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -769,14 +769,14 @@ function SkillSettings() { if (selectedSkill) { return ( -
+
) } return ( -
+

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

diff --git a/desktop/src/types/skill.ts b/desktop/src/types/skill.ts index 48cb8b39..43511a32 100644 --- a/desktop/src/types/skill.ts +++ b/desktop/src/types/skill.ts @@ -19,10 +19,15 @@ export type FileTreeNode = { children?: FileTreeNode[] } +export type SkillFrontmatter = Record + export type SkillFile = { path: string content: string language: string + frontmatter?: SkillFrontmatter + body?: string + isEntry?: boolean } export type SkillDetail = { diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 20bf3db2..1403e61a 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -9,7 +9,7 @@ import * as os from 'os' import * as path from 'path' import * as fs from 'fs/promises' -import { parse as parseYaml } from 'yaml' +import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' // ─── Types ─────────────────────────────────────────────────────────────────── @@ -36,6 +36,9 @@ type SkillFile = { path: string content: string language: string + frontmatter?: Record + body?: string + isEntry?: boolean } // ─── Constants ─────────────────────────────────────────────────────────────── @@ -59,17 +62,14 @@ function detectLanguage(filename: string): string { return LANG_MAP[ext] || 'text' } -function parseFrontmatter(content: string): { +function normalizeFrontmatter(content: string, sourcePath?: string): { frontmatter: Record body: string } { - const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/) - if (!match) return { frontmatter: {}, body: content } - try { - const frontmatter = (parseYaml(match[1]) as Record) || {} - return { frontmatter, body: content.slice(match[0].length) } - } catch { - return { frontmatter: {}, body: content } + const parsed = parseFrontmatter(content, sourcePath) + return { + frontmatter: parsed.frontmatter as Record, + body: parsed.content, } } @@ -85,7 +85,7 @@ async function loadSkillMeta( const skillFile = path.join(skillDir, 'SKILL.md') try { const raw = await fs.readFile(skillFile, 'utf-8') - const { frontmatter, body } = parseFrontmatter(raw) + const { frontmatter, body } = normalizeFrontmatter(raw, skillFile) const description = (frontmatter.description as string) || @@ -157,11 +157,27 @@ async function buildFileTree( const stat = await fs.stat(fullPath) if (stat.size <= MAX_FILE_SIZE) { const content = await fs.readFile(fullPath, 'utf-8') - files.push({ - path: relPath, - content, - language: detectLanguage(entry.name), - }) + const language = detectLanguage(entry.name) + const isEntry = relPath === 'SKILL.md' + + if (isEntry && language === 'markdown') { + const { frontmatter, body } = normalizeFrontmatter(content, fullPath) + files.push({ + path: relPath, + content: body, + body, + frontmatter, + language, + isEntry: true, + }) + } else { + files.push({ + path: relPath, + content, + language, + isEntry: false, + }) + } fileCount++ } } catch {