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 }) => (
-
({
+ 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 (