cc-haha/docs/superpowers/plans/2026-04-09-skill-ui.md
程序员阿江(Relakkes) 9bb05adc74 docs: add Skills UI design spec and implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:18:08 +08:00

32 KiB

Skills UI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a Skills browser to the Desktop Web APP Settings page, with API backend, file tree navigation, and Markdown/code content preview.

Architecture: Server reads skill directories from ~/.claude/skills/, exposes two REST endpoints (list + detail). Desktop frontend adds a "Skills" tab to Settings with list/detail views, reusing existing MarkdownRenderer and CodeViewer components.

Tech Stack: Bun server (REST API), React + Zustand + Tailwind CSS (desktop), marked + react-shiki (rendering)


File Structure

Action File Responsibility
Create src/server/api/skills.ts Server API handler: list skills, read skill directory tree + files
Modify src/server/router.ts Register /api/skills route
Create desktop/src/types/skill.ts Shared type definitions
Create desktop/src/api/skills.ts API client for skills endpoints
Create desktop/src/stores/skillStore.ts Zustand store for skill state
Create desktop/src/components/skills/SkillList.tsx Grouped skill list view
Create desktop/src/components/skills/SkillDetail.tsx File tree + content viewer
Modify desktop/src/pages/Settings.tsx Add Skills tab + SkillSettings wrapper
Modify desktop/src/i18n/locales/en.ts English translation keys
Modify desktop/src/i18n/locales/zh.ts Chinese translation keys

Task 1: Desktop Type Definitions

Files:

  • Create: desktop/src/types/skill.ts

  • Step 1: Create type definitions

// desktop/src/types/skill.ts

export type SkillSource = 'user' | 'project' | 'plugin' | 'mcp' | 'bundled'

export type SkillMeta = {
  name: string
  displayName?: string
  description: string
  source: SkillSource
  userInvocable: boolean
  version?: string
  contentLength: number
  hasDirectory: boolean
  pluginName?: string
}

export type FileTreeNode = {
  name: string
  path: string
  type: 'file' | 'directory'
  children?: FileTreeNode[]
}

export type SkillFile = {
  path: string
  content: string
  language: string
}

export type SkillDetail = {
  meta: SkillMeta
  tree: FileTreeNode[]
  files: SkillFile[]
  skillRoot: string
}
  • Step 2: Commit
git add desktop/src/types/skill.ts
git commit -m "feat(skills): add type definitions for Skills UI"

Task 2: Server API Handler

Files:

  • Create: src/server/api/skills.ts

  • Modify: src/server/router.ts:1-79

  • Step 1: Create the skills API handler

// src/server/api/skills.ts

/**
 * Skills REST API
 *
 * GET /api/skills              — List all installed skills (metadata only)
 * GET /api/skills/detail       — Full skill data (tree + files)
 *       ?source=user&name=xxx
 */

import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs/promises'
import { parse as parseYaml } from 'yaml'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'

// ─── Types (server-local, mirrors desktop/src/types/skill.ts) ────────────────

type SkillMeta = {
  name: string
  displayName?: string
  description: string
  source: 'user' | 'project'
  userInvocable: boolean
  version?: string
  contentLength: number
  hasDirectory: boolean
}

type FileTreeNode = {
  name: string
  path: string
  type: 'file' | 'directory'
  children?: FileTreeNode[]
}

type SkillFile = {
  path: string
  content: string
  language: string
}

// ─── Constants ───────────────────────────────────────────────────────────────

const MAX_FILES = 50
const MAX_FILE_SIZE = 100 * 1024 // 100 KB
const SKIP_ENTRIES = new Set(['node_modules', '.git', '__pycache__', '.DS_Store'])

const LANG_MAP: Record<string, string> = {
  md: 'markdown', ts: 'typescript', tsx: 'typescript',
  js: 'javascript', jsx: 'javascript', json: 'json',
  yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash',
  py: 'python', toml: 'toml', css: 'css', html: 'html',
  txt: 'text', xml: 'xml', sql: 'sql', rs: 'rust', go: 'go',
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function detectLanguage(filename: string): string {
  const ext = filename.split('.').pop()?.toLowerCase() || ''
  return LANG_MAP[ext] || 'text'
}

function parseFrontmatter(content: 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 }
  }
}

function getUserSkillsDir(): string {
  return path.join(os.homedir(), '.claude', 'skills')
}

async function loadSkillMeta(
  skillDir: string,
  skillName: string,
  source: 'user' | 'project',
): Promise<SkillMeta | null> {
  const skillFile = path.join(skillDir, 'SKILL.md')
  try {
    const raw = await fs.readFile(skillFile, 'utf-8')
    const { frontmatter, body } = parseFrontmatter(raw)

    const description =
      (frontmatter.description as string) ||
      body
        .split('\n')
        .find((l) => l.trim().length > 0)
        ?.trim() ||
      'No description'

    return {
      name: skillName,
      displayName: (frontmatter.name as string) || undefined,
      description,
      source,
      userInvocable: frontmatter['user-invocable'] !== false,
      version: frontmatter.version != null ? String(frontmatter.version) : undefined,
      contentLength: raw.length,
      hasDirectory: true,
    }
  } catch {
    return null
  }
}

async function buildFileTree(
  dirPath: string,
): Promise<{ tree: FileTreeNode[]; files: SkillFile[] }> {
  const tree: FileTreeNode[] = []
  const files: SkillFile[] = []
  let fileCount = 0

  async function walk(currentPath: string, nodes: FileTreeNode[]) {
    if (fileCount >= MAX_FILES) return

    let entries: import('fs').Dirent[]
    try {
      entries = await fs.readdir(currentPath, { withFileTypes: true })
    } catch {
      return
    }

    // directories first, then alphabetical
    entries.sort((a, b) => {
      if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
      return a.name.localeCompare(b.name)
    })

    for (const entry of entries) {
      if (fileCount >= MAX_FILES) break
      if (SKIP_ENTRIES.has(entry.name) || entry.name.startsWith('.')) continue

      const fullPath = path.join(currentPath, entry.name)
      const relPath = path.relative(dirPath, fullPath)

      if (entry.isDirectory()) {
        const node: FileTreeNode = {
          name: entry.name,
          path: relPath,
          type: 'directory',
          children: [],
        }
        nodes.push(node)
        await walk(fullPath, node.children!)
        if (node.children!.length === 0) delete node.children
      } else if (entry.isFile()) {
        nodes.push({ name: entry.name, path: relPath, type: 'file' })

        try {
          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),
            })
            fileCount++
          }
        } catch {
          // skip unreadable files
        }
      }
    }
  }

  await walk(dirPath, tree)
  return { tree, files }
}

// ─── Router ──────────────────────────────────────────────────────────────────

export async function handleSkillsApi(
  req: Request,
  url: URL,
  segments: string[],
): Promise<Response> {
  try {
    if (req.method !== 'GET') {
      throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
    }

    const sub = segments[2]

    switch (sub) {
      case undefined:
        return await listSkills()
      case 'detail':
        return await getSkillDetail(url)
      default:
        throw ApiError.notFound(`Unknown skills endpoint: ${sub}`)
    }
  } catch (error) {
    return errorResponse(error)
  }
}

// ─── Handlers ────────────────────────────────────────────────────────────────

async function listSkills(): Promise<Response> {
  const skillsDir = getUserSkillsDir()
  const skills: SkillMeta[] = []

  try {
    const entries = await fs.readdir(skillsDir, { withFileTypes: true })
    for (const entry of entries) {
      if (!entry.isDirectory() || entry.name.startsWith('.')) continue
      const meta = await loadSkillMeta(
        path.join(skillsDir, entry.name),
        entry.name,
        'user',
      )
      if (meta) skills.push(meta)
    }
  } catch {
    // skills dir doesn't exist — return empty list
  }

  skills.sort((a, b) => a.name.localeCompare(b.name))
  return Response.json({ skills })
}

async function getSkillDetail(url: URL): Promise<Response> {
  const source = url.searchParams.get('source')
  const name = url.searchParams.get('name')

  if (!source || !name) {
    throw ApiError.badRequest('Missing required query parameters: source, name')
  }

  // Prevent path traversal
  if (name.includes('..') || name.includes('/') || name.includes('\\')) {
    throw ApiError.badRequest('Invalid skill name')
  }

  let skillDir: string
  if (source === 'user') {
    skillDir = path.join(getUserSkillsDir(), name)
  } else {
    throw ApiError.badRequest(`Unsupported source: ${source}`)
  }

  try {
    const stat = await fs.stat(skillDir)
    if (!stat.isDirectory()) throw new Error()
  } catch {
    throw ApiError.notFound(`Skill not found: ${name}`)
  }

  const meta = await loadSkillMeta(skillDir, name, source as 'user')
  if (!meta) {
    throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
  }

  const { tree, files } = await buildFileTree(skillDir)

  return Response.json({
    detail: { meta, tree, files, skillRoot: skillDir },
  })
}
  • Step 2: Register the route in router.ts

In src/server/router.ts, add the import and case:

// Add import at the top (after existing imports):
import { handleSkillsApi } from './api/skills.js'

// Add case in the switch (before `case 'filesystem':`):
    case 'skills':
      return handleSkillsApi(req, url, segments)
  • Step 3: Verify server compiles

Run: cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui && bun build src/server/index.ts --outdir /tmp/skill-check --target bun 2>&1 | tail -5

Expected: No type errors, successful build output.

  • Step 4: Commit
git add src/server/api/skills.ts src/server/router.ts
git commit -m "feat(skills): add GET /api/skills and /api/skills/detail endpoints"

Task 3: Desktop API Client

Files:

  • Create: desktop/src/api/skills.ts

  • Step 1: Create the API client

// desktop/src/api/skills.ts

import { api } from './client'
import type { SkillMeta, SkillDetail } from '../types/skill'

export const skillsApi = {
  list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),

  detail: (source: string, name: string) =>
    api.get<{ detail: SkillDetail }>(
      `/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`,
    ),
}
  • Step 2: Commit
git add desktop/src/api/skills.ts
git commit -m "feat(skills): add desktop API client for skills endpoints"

Task 4: Zustand Store

Files:

  • Create: desktop/src/stores/skillStore.ts

  • Step 1: Create the skill store

// desktop/src/stores/skillStore.ts

import { create } from 'zustand'
import { skillsApi } from '../api/skills'
import type { SkillMeta, SkillDetail } from '../types/skill'

type SkillStore = {
  skills: SkillMeta[]
  selectedSkill: SkillDetail | null
  isLoading: boolean
  isDetailLoading: boolean
  error: string | null

  fetchSkills: () => Promise<void>
  fetchSkillDetail: (source: string, name: string) => Promise<void>
  clearSelection: () => void
}

export const useSkillStore = create<SkillStore>((set) => ({
  skills: [],
  selectedSkill: null,
  isLoading: false,
  isDetailLoading: false,
  error: null,

  fetchSkills: async () => {
    set({ isLoading: true, error: null })
    try {
      const { skills } = await skillsApi.list()
      set({ skills, isLoading: false })
    } catch (err) {
      set({
        error: err instanceof Error ? err.message : String(err),
        isLoading: false,
      })
    }
  },

  fetchSkillDetail: async (source, name) => {
    set({ isDetailLoading: true, error: null })
    try {
      const { detail } = await skillsApi.detail(source, name)
      set({ selectedSkill: detail, isDetailLoading: false })
    } catch (err) {
      set({
        error: err instanceof Error ? err.message : String(err),
        isDetailLoading: false,
      })
    }
  },

  clearSelection: () => set({ selectedSkill: null }),
}))
  • Step 2: Commit
git add desktop/src/stores/skillStore.ts
git commit -m "feat(skills): add Zustand store for skill state management"

Task 5: i18n Translation Keys

Files:

  • Modify: desktop/src/i18n/locales/en.ts:47-50

  • Modify: desktop/src/i18n/locales/zh.ts:47-50

  • Step 1: Add English translations

In desktop/src/i18n/locales/en.ts, add after 'settings.tab.general': 'General', (line 49):

  'settings.tab.skills': 'Skills',

Then add a new section after the // Settings > Adapters block (before // Settings > General). Insert after line 140 ('settings.adapters.platform.feishu': 'Feishu',):


  // Settings > Skills
  'settings.skills.title': 'Installed Skills',
  'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
  '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.source.user': 'User',
  'settings.skills.source.project': 'Project',
  'settings.skills.source.plugin': 'Plugin',
  'settings.skills.source.mcp': 'MCP',
  'settings.skills.source.bundled': 'Built-in',
  • Step 2: Add Chinese translations

In desktop/src/i18n/locales/zh.ts, add after the settings.tab.general line (around line 50):

  'settings.tab.skills': '技能',

Then add matching keys after the adapters section:


  // Settings > Skills
  'settings.skills.title': '已安装技能',
  'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
  'settings.skills.empty': '暂无已安装技能',
  'settings.skills.emptyHint': '在 ~/.claude/skills/ 中添加技能即可开始',
  'settings.skills.back': '返回列表',
  'settings.skills.files': '个文件',
  'settings.skills.source.user': '用户',
  'settings.skills.source.project': '项目',
  'settings.skills.source.plugin': '插件',
  'settings.skills.source.mcp': 'MCP',
  'settings.skills.source.bundled': '内置',
  • Step 3: Verify TypeScript is happy

Run: cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui/desktop && npx tsc --noEmit 2>&1 | tail -5

Expected: No errors (the zh.ts Record type enforces key parity with en.ts).

  • Step 4: Commit
git add desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts
git commit -m "feat(skills): add i18n translation keys for Skills UI"

Task 6: SkillList Component

Files:

  • Create: desktop/src/components/skills/SkillList.tsx

  • Step 1: Create the skill list component

// desktop/src/components/skills/SkillList.tsx

import { useEffect } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import type { SkillMeta, SkillSource } from '../../types/skill'

const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']

const SOURCE_ICONS: Record<SkillSource, string> = {
  user: 'person',
  project: 'folder',
  plugin: 'extension',
  mcp: 'hub',
  bundled: 'inventory_2',
}

export function SkillList() {
  const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
    useSkillStore()
  const t = useTranslation()

  useEffect(() => {
    fetchSkills()
  }, [fetchSkills])

  if (isLoading) {
    return (
      <div className="flex justify-center py-12">
        <div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
      </div>
    )
  }

  if (error) {
    return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
  }

  if (skills.length === 0) {
    return (
      <div className="text-center py-12">
        <span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
          auto_awesome
        </span>
        <p className="text-sm text-[var(--color-text-tertiary)]">
          {t('settings.skills.empty')}
        </p>
        <p className="text-xs text-[var(--color-text-tertiary)] mt-1">
          {t('settings.skills.emptyHint')}
        </p>
      </div>
    )
  }

  // 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>

            {/* 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
                    </span>
                    {skill.hasDirectory && (
                      <span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
                        chevron_right
                      </span>
                    )}
                  </div>
                </button>
              ))}
            </div>
          </div>
        )
      })}
    </div>
  )
}
  • Step 2: Commit
git add desktop/src/components/skills/SkillList.tsx
git commit -m "feat(skills): add SkillList component with grouped display"

Task 7: SkillDetail Component

Files:

  • Create: desktop/src/components/skills/SkillDetail.tsx

  • Step 1: Create the skill detail component with file tree and viewer

// desktop/src/components/skills/SkillDetail.tsx

import { useState } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { CodeViewer } from '../chat/CodeViewer'
import type { FileTreeNode } from '../../types/skill'

// ─── Main Component ──────────────────────────────────────────────────────────

export function SkillDetail() {
  const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
  const t = useTranslation()
  const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')

  if (isDetailLoading) {
    return (
      <div className="flex justify-center py-12">
        <div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
      </div>
    )
  }

  if (!selectedSkill) return null

  const { meta, tree, files } = selectedSkill
  const currentFile = files.find((f) => f.path === selectedFile) || files[0]

  return (
    <div className="flex flex-col h-full">
      {/* Back button */}
      <div className="mb-3">
        <button
          onClick={clearSelection}
          className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
        >
          <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>

      {/* 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>

        {/* 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>
              </div>

              {/* Content */}
              <div className="p-4">
                {currentFile.language === 'markdown' ? (
                  <MarkdownRenderer content={currentFile.content} />
                ) : (
                  <CodeViewer
                    code={currentFile.content}
                    language={currentFile.language}
                    maxLines={9999}
                    showLineNumbers
                  />
                )}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

// ─── File Tree Components ────────────────────────────────────────────────────

function TreeView({
  nodes,
  selectedPath,
  onSelect,
  depth,
}: {
  nodes: FileTreeNode[]
  selectedPath: string
  onSelect: (path: string) => void
  depth: number
}) {
  return (
    <>
      {nodes.map((node) => (
        <TreeItem
          key={node.path}
          node={node}
          selectedPath={selectedPath}
          onSelect={onSelect}
          depth={depth}
        />
      ))}
    </>
  )
}

function TreeItem({
  node,
  selectedPath,
  onSelect,
  depth,
}: {
  node: FileTreeNode
  selectedPath: string
  onSelect: (path: string) => void
  depth: number
}) {
  const [expanded, setExpanded] = useState(true)
  const isSelected = node.path === selectedPath
  const isDir = node.type === 'directory'

  const icon = isDir
    ? expanded
      ? 'folder_open'
      : 'folder'
    : fileIcon(node.name)

  return (
    <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 ${
          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` }}
      >
        {isDir ? (
          <span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
            {expanded ? 'expand_more' : 'chevron_right'}
          </span>
        ) : (
          <span style={{ width: 12 }} />
        )}
        <span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
          {icon}
        </span>
        <span className="truncate">{node.name}</span>
      </button>

      {isDir && expanded && node.children && (
        <TreeView
          nodes={node.children}
          selectedPath={selectedPath}
          onSelect={onSelect}
          depth={depth + 1}
        />
      )}
    </div>
  )
}

function fileIcon(filename: string): string {
  const ext = filename.split('.').pop()?.toLowerCase()
  switch (ext) {
    case 'md':
      return 'description'
    case 'ts':
    case 'tsx':
    case 'js':
    case 'jsx':
    case 'py':
    case 'rs':
    case 'go':
      return 'code'
    case 'json':
    case 'yaml':
    case 'yml':
    case 'toml':
      return 'data_object'
    case 'sh':
    case 'bash':
      return 'terminal'
    default:
      return 'draft'
  }
}
  • Step 2: Commit
git add desktop/src/components/skills/SkillDetail.tsx
git commit -m "feat(skills): add SkillDetail component with file tree and content viewer"

Task 8: Settings Page Integration

Files:

  • Modify: desktop/src/pages/Settings.tsx:1-58

  • Step 1: Add Skills tab to Settings page

In desktop/src/pages/Settings.tsx, apply these changes:

1. Add imports at the top (after existing imports):

import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'

2. Extend the SettingsTab type (line 15):

Change:

type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'

To:

type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills'

3. Add the TabButton in the Settings component (after the adapters TabButton, around line 29):

          <TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />

4. Add the content renderer (after the adapters condition, around line 35):

          {activeTab === 'skills' && <SkillSettings />}

5. Add the SkillSettings wrapper component at the bottom of the file (before the closing line, after GeneralSettings):

// ─── Skill Settings ──────────────────────────────────────

function SkillSettings() {
  const selectedSkill = useSkillStore((s) => s.selectedSkill)
  const t = useTranslation()

  if (selectedSkill) {
    return (
      <div className="max-w-3xl">
        <SkillDetail />
      </div>
    )
  }

  return (
    <div className="max-w-2xl">
      <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
        {t('settings.skills.title')}
      </h2>
      <p className="text-sm text-[var(--color-text-tertiary)] mb-4">
        {t('settings.skills.description')}
      </p>
      <SkillList />
    </div>
  )
}
  • Step 2: Verify the desktop app compiles

Run: cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui/desktop && npx tsc --noEmit 2>&1 | tail -10

Expected: No type errors.

  • Step 3: Commit
git add desktop/src/pages/Settings.tsx
git commit -m "feat(skills): integrate Skills tab into Settings page"

Task 9: Manual Smoke Test

  • Step 1: Create a test skill directory
mkdir -p ~/.claude/skills/test-skill
cat > ~/.claude/skills/test-skill/SKILL.md << 'SKILLEOF'
---
name: Test Skill
description: A test skill for verifying the Skills UI
version: 1.0
user-invocable: true
---

# Test Skill

This is a test skill to verify the Skills browser works correctly.

## Usage

Just invoke `/test-skill` from the chat.

```typescript
console.log('Hello from test skill')

SKILLEOF

cat > ~/.claude/skills/test-skill/helper.ts << 'HELPEREOF' export function greet(name: string): string { return Hello, ${name}! } HELPEREOF


- [ ] **Step 2: Start the server and verify API**

Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui && bun run src/server/index.ts &`

Then test:
```bash
curl -s http://127.0.0.1:3456/api/skills | python3 -m json.tool

Expected: JSON with skills array containing test-skill entry.

curl -s "http://127.0.0.1:3456/api/skills/detail?source=user&name=test-skill" | python3 -m json.tool

Expected: JSON with detail containing meta, tree (SKILL.md + helper.ts), files with content.

  • Step 3: Clean up test data (optional)
rm -rf ~/.claude/skills/test-skill
  • Step 4: Final commit with all changes
git add -A
git commit -m "feat: add Skills browser to Settings page

- Server: GET /api/skills (list) and GET /api/skills/detail (tree + files)
- Desktop: Skills tab in Settings with list/detail views
- File tree navigation with Markdown and code preview
- i18n support (EN/ZH)"