mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
78c7b23c11
commit
5911388626
@ -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 }) => (
|
||||
<div data-testid="markdown-renderer">{content}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// 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: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../components/chat/CodeViewer', () => ({
|
||||
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
|
||||
}))
|
||||
|
||||
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(<Settings />)
|
||||
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(<Settings />)
|
||||
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(<Settings />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
159
desktop/src/__tests__/skillsSettings.test.tsx
Normal file
159
desktop/src/__tests__/skillsSettings.test.tsx
Normal file
@ -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: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
|
||||
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 }) => <pre data-testid="code-viewer">{code}</pre>,
|
||||
}))
|
||||
|
||||
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(<Settings />)
|
||||
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(<Settings />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
39
desktop/src/components/markdown/MarkdownRenderer.test.tsx
Normal file
39
desktop/src/components/markdown/MarkdownRenderer.test.tsx
Normal file
@ -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(
|
||||
<MarkdownRenderer
|
||||
content={'# Skill Title\n\nReadable paragraph text.'}
|
||||
variant="document"
|
||||
className="mx-auto max-w-[72ch]"
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MarkdownRenderer content={'## Default Heading\n\nBody copy.'} />,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div
|
||||
|
||||
89
desktop/src/components/skills/SkillDetail.test.tsx
Normal file
89
desktop/src/components/skills/SkillDetail.test.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
vi.mock('../markdown/MarkdownRenderer', () => ({
|
||||
MarkdownRenderer: ({
|
||||
content,
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
content: string
|
||||
variant?: string
|
||||
className?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="markdown-renderer"
|
||||
data-content={content}
|
||||
data-variant={variant}
|
||||
data-classname={className}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../chat/CodeViewer', () => ({
|
||||
CodeViewer: ({ code }: { code: string }) => <div data-testid="code-viewer">{code}</div>,
|
||||
}))
|
||||
|
||||
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(<SkillDetail />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@ -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<string>('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 (
|
||||
<div className="flex justify-center py-12">
|
||||
@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Back button */}
|
||||
<div className="mb-3">
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
|
||||
<div>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] 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]">
|
||||
arrow_back
|
||||
</span>
|
||||
<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>
|
||||
<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 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||
{t('settings.skills.entryEyebrow')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
|
||||
{meta.displayName || meta.name}
|
||||
</h3>
|
||||
<MetaPill>{t(`settings.skills.source.${meta.source}`)}</MetaPill>
|
||||
{meta.version && <MetaPill>v{meta.version}</MetaPill>}
|
||||
{meta.userInvocable && <MetaPill>{t('settings.skills.slashCommand')}</MetaPill>}
|
||||
</div>
|
||||
<p className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{meta.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.skills.tokenEstimate', { count: String(Math.ceil(meta.contentLength / 4)) })}</span>
|
||||
<span>
|
||||
{files.length} {t('settings.skills.files')}
|
||||
</span>
|
||||
<span>{currentFile?.isEntry ? t('settings.skills.entryFile') : currentFile?.path}</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 className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.totalFiles')}
|
||||
value={String(files.length)}
|
||||
icon="folder_open"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.tokens')}
|
||||
value={t('settings.skills.tokenEstimateShort', { count: String(Math.ceil(meta.contentLength / 4)) })}
|
||||
icon="notes"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.source')}
|
||||
value={t(`settings.skills.source.${meta.source}`)}
|
||||
icon="layers"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.entry')}
|
||||
value={files.some((file) => file.isEntry) ? 'SKILL.md' : '—'}
|
||||
icon="article"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
{metaEntries.length > 0 && (
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
||||
tune
|
||||
</span>
|
||||
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.skills.metaTitle')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{metaEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 min-w-0"
|
||||
>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
{formatMetaKey(key)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--color-text-primary)] break-words">
|
||||
{formatMetaValue(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<aside className="hidden w-[250px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:flex lg:flex-col">
|
||||
<div className="border-b border-[var(--color-border)] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.filesPanel')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.filesPanelHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<TreeView
|
||||
nodes={tree}
|
||||
selectedPath={normalizedSelection}
|
||||
onSelect={setSelectedFile}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
|
||||
{currentFile?.path}
|
||||
</span>
|
||||
{currentFile?.isEntry && <MetaPill>{t('settings.skills.entryFile')}</MetaPill>}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.readingMode', {
|
||||
mode:
|
||||
currentFile?.language === 'markdown'
|
||||
? t('settings.skills.docMode')
|
||||
: t('settings.skills.codeMode'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
|
||||
{currentFile?.language}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max">
|
||||
{files.map((file) => {
|
||||
const active = file.path === normalizedSelection
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(file.path)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
||||
active
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{file.path}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
|
||||
{currentFile && (
|
||||
<div className={currentFile.language === 'markdown' ? 'px-6 py-5 lg:px-8' : 'p-4'}>
|
||||
{currentFile.language === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentFile.content} />
|
||||
<MarkdownRenderer
|
||||
content={currentFile.body ?? currentFile.content}
|
||||
variant="document"
|
||||
className="mx-auto max-w-[72ch]"
|
||||
/>
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={currentFile.content}
|
||||
@ -101,16 +221,14 @@ export function SkillDetail() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<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 ${
|
||||
className={`flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
||||
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` }}
|
||||
style={{ marginLeft: `${depth * 12}px`, width: `calc(100% - ${depth * 12}px)` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
|
||||
@ -194,6 +308,74 @@ function TreeItem({
|
||||
)
|
||||
}
|
||||
|
||||
function DetailStat({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaPill({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getMetaEntries(frontmatter?: SkillFrontmatter): Array<[string, unknown]> {
|
||||
if (!frontmatter) return []
|
||||
|
||||
const entries = Object.entries(frontmatter).filter(([, value]) => {
|
||||
if (value == null) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return true
|
||||
})
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const aIndex = META_PRIORITY.indexOf(a[0] as (typeof META_PRIORITY)[number])
|
||||
const bIndex = META_PRIORITY.indexOf(b[0] as (typeof META_PRIORITY)[number])
|
||||
const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex
|
||||
const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex
|
||||
return normalizedA - normalizedB || a[0].localeCompare(b[0])
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function formatMetaKey(key: string) {
|
||||
return key.replace(/[-_]/g, ' ')
|
||||
}
|
||||
|
||||
function formatMetaValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function fileIcon(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SkillMeta, SkillSource } from '../../types/skill'
|
||||
@ -13,6 +13,18 @@ const SOURCE_ICONS: Record<SkillSource, string> = {
|
||||
bundled: 'inventory_2',
|
||||
}
|
||||
|
||||
const SOURCE_ACCENT_CLASSES: Record<SkillSource, string> = {
|
||||
user: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]',
|
||||
project: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
|
||||
plugin: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]',
|
||||
mcp: 'bg-[var(--color-info-container)] text-[var(--color-info)]',
|
||||
bundled: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
|
||||
}
|
||||
|
||||
function estimateTokens(contentLength: number) {
|
||||
return Math.ceil(contentLength / 4)
|
||||
}
|
||||
|
||||
export function SkillList() {
|
||||
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
||||
useSkillStore()
|
||||
@ -22,6 +34,20 @@ export function SkillList() {
|
||||
fetchSkills()
|
||||
}, [fetchSkills])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
||||
for (const skill of skills) {
|
||||
const src = skill.source as SkillSource
|
||||
;(result[src] ??= []).push(skill)
|
||||
}
|
||||
return result
|
||||
}, [skills])
|
||||
|
||||
const totalTokens = useMemo(
|
||||
() => skills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
|
||||
[skills],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@ -36,7 +62,7 @@ export function SkillList() {
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<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">
|
||||
auto_awesome
|
||||
</span>
|
||||
@ -50,73 +76,169 @@ export function SkillList() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 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 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md: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.skills.browserEyebrow')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
||||
auto_awesome
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.skills.browserTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
|
||||
{t('settings.skills.browserDescription')}
|
||||
</p>
|
||||
</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
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<SummaryCard
|
||||
label={t('settings.skills.summary.totalSkills')}
|
||||
value={String(skills.length)}
|
||||
icon="auto_awesome"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.skills.summary.sources')}
|
||||
value={String(
|
||||
SOURCE_ORDER.filter((source) => (grouped[source] ?? []).length > 0)
|
||||
.length,
|
||||
)}
|
||||
icon="layers"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t('settings.skills.summary.tokens')}
|
||||
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
|
||||
icon="notes"
|
||||
className="col-span-2 md:col-span-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{SOURCE_ORDER.map((source) => {
|
||||
const group = grouped[source]
|
||||
if (!group?.length) return null
|
||||
|
||||
const sourceLabel = t(`settings.skills.source.${source}`)
|
||||
const sourceTokenCount = group.reduce(
|
||||
(sum, skill) => sum + estimateTokens(skill.contentLength),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<section
|
||||
key={source}
|
||||
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${SOURCE_ACCENT_CLASSES[source]}`}>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{SOURCE_ICONS[source]}
|
||||
</span>
|
||||
</span>
|
||||
{skill.hasDirectory && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{sourceLabel}
|
||||
</h4>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{group.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.groupHint', {
|
||||
source: sourceLabel,
|
||||
count: String(group.length),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] whitespace-nowrap">
|
||||
{t('settings.skills.tokenEstimateShort', { count: String(sourceTokenCount) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-2">
|
||||
{group.map((skill) => (
|
||||
<button
|
||||
key={`${skill.source}-${skill.name}`}
|
||||
onClick={() =>
|
||||
skill.hasDirectory &&
|
||||
fetchSkillDetail(skill.source, skill.name)
|
||||
}
|
||||
disabled={!skill.hasDirectory}
|
||||
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
||||
auto_awesome
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
|
||||
{skill.displayName || skill.name}
|
||||
</span>
|
||||
{skill.version && (
|
||||
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
v{skill.version}
|
||||
</span>
|
||||
)}
|
||||
{skill.userInvocable && (
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.slashCommand')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
||||
{skill.description}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span>{sourceLabel}</span>
|
||||
<span>{t('settings.skills.tokenEstimateShort', { count: String(estimateTokens(skill.contentLength)) })}</span>
|
||||
<span>{skill.hasDirectory ? t('settings.skills.ready') : t('settings.skills.unavailable')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
|
||||
chevron_right
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
className = '',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -159,10 +159,33 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// 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': '插件',
|
||||
|
||||
@ -769,14 +769,14 @@ function SkillSettings() {
|
||||
|
||||
if (selectedSkill) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="w-full min-w-0 max-w-[1400px]">
|
||||
<SkillDetail />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="w-full min-w-0 max-w-[1280px]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{t('settings.skills.title')}
|
||||
</h2>
|
||||
|
||||
@ -19,10 +19,15 @@ export type FileTreeNode = {
|
||||
children?: FileTreeNode[]
|
||||
}
|
||||
|
||||
export type SkillFrontmatter = Record<string, unknown>
|
||||
|
||||
export type SkillFile = {
|
||||
path: string
|
||||
content: string
|
||||
language: string
|
||||
frontmatter?: SkillFrontmatter
|
||||
body?: string
|
||||
isEntry?: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = {
|
||||
|
||||
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>) || {}
|
||||
return { frontmatter, body: content.slice(match[0].length) }
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content }
|
||||
const parsed = parseFrontmatter(content, sourcePath)
|
||||
return {
|
||||
frontmatter: parsed.frontmatter as Record<string, unknown>,
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user