diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 0398840b..d8bec08b 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -1,12 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Settings } from '../pages/Settings' -import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' -import { useSessionStore } from '../stores/sessionStore' -import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore' +import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' vi.mock('../api/agents', () => ({ @@ -50,252 +48,42 @@ vi.mock('../stores/agentStore', () => ({ }), })) -vi.mock('../components/chat/CodeViewer', () => ({ - CodeViewer: ({ code }: { code: string }) =>
{code}
, -})) - -const MOCK_FETCH_SKILLS = vi.fn() -const MOCK_FETCH_SKILL_DETAIL = vi.fn() -const MOCK_CLEAR_SELECTION = vi.fn() - -function switchToSkillsTab() { - fireEvent.click(screen.getByText('Skills')) -} - -describe('Settings > Skills tab', () => { +describe('Settings > Skills compatibility entry', () => { beforeEach(() => { vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) - useSessionStore.setState({ - sessions: [ - { - id: 'session-1', - title: 'Active session', - createdAt: '2026-04-20T00:00:00.000Z', - modifiedAt: '2026-04-20T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }, - ], - activeSessionId: 'session-1', - isLoading: false, - error: null, - }) - useTabStore.setState({ tabs: [], activeTabId: null }) - useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null }) - useSkillStore.setState({ - skills: [], - selectedSkill: null, - selectedSkillReturnTab: 'skills', - isLoading: false, - isDetailLoading: false, - error: null, - fetchSkills: MOCK_FETCH_SKILLS, - fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, - clearSelection: MOCK_CLEAR_SELECTION, + useTabStore.setState(useTabStore.getInitialState(), true) + useUIStore.setState({ + activeSettingsTab: 'providers', + pendingSettingsTab: null, }) }) - 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, - }, - { - name: 'telegram:access', - displayName: 'Telegram Access', - description: 'Plugin-provided access workflow', - source: 'plugin', - pluginName: 'telegram', - userInvocable: true, - contentLength: 280, - hasDirectory: true, - }, - ], - }) - + it('opens the unified Skill Center from the legacy settings tab button', () => { render() - switchToSkillsTab() - expect(screen.getByText('Browse installed skills')).toBeInTheDocument() - expect(screen.getByText('Skill Browser')).toBeInTheDocument() - expect(screen.getByText('Total skills')).toBeInTheDocument() - expect(screen.getByText('Alpha Skill')).toBeInTheDocument() - expect(screen.getByText('Second skill description')).toBeInTheDocument() - expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) - expect(screen.getByText('Telegram Access')).toBeInTheDocument() + fireEvent.click(screen.getByText('Skills')) + + expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID) + expect(useTabStore.getState().tabs).toContainEqual({ + sessionId: SKILL_CENTER_TAB_ID, + title: 'Skills', + type: 'skill-center', + status: 'idle', + }) }) - it('filters installed skills locally by keyword and clears the search', () => { - useSkillStore.setState({ - skills: [ - { - name: 'alpha', - displayName: 'Alpha Skill', - description: 'First skill description', - source: 'user', - userInvocable: true, - contentLength: 400, - hasDirectory: true, - }, - { - name: 'telegram:access', - displayName: 'Telegram Access', - description: 'Plugin-provided access workflow', - source: 'plugin', - pluginName: 'telegram', - userInvocable: true, - contentLength: 280, - hasDirectory: true, - }, - ], + it('redirects pending legacy skills settings navigation to the Skill Center', async () => { + useUIStore.setState({ + activeSettingsTab: 'providers', + pendingSettingsTab: 'skills', }) render() - switchToSkillsTab() - const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...') - fireEvent.change(searchInput, { target: { value: 'telegram' } }) - - expect(screen.getByText('Telegram Access')).toBeInTheDocument() - expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument() - expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument() - - fireEvent.click(screen.getByLabelText('Clear skill search')) - - expect(screen.getByText('Telegram Access')).toBeInTheDocument() - expect(screen.getByText('Alpha Skill')).toBeInTheDocument() - }) - - it('uses the active session workDir even when settings tab is focused', () => { - const fetchSkills = vi.fn() - useSkillStore.setState({ - skills: [], - selectedSkill: null, - isLoading: false, - isDetailLoading: false, - error: null, - fetchSkills, - fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, - clearSelection: MOCK_CLEAR_SELECTION, + await waitFor(() => { + expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID) }) - useTabStore.setState({ - activeTabId: SETTINGS_TAB_ID, - tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }], - }) - - render() - switchToSkillsTab() - - expect(fetchSkills).toHaveBeenCalledWith('/workspace/project') - }) - - 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', - }, - selectedSkillReturnTab: 'skills', - }) - - render() - switchToSkillsTab() - - expect(screen.getByText('Skill metadata')).toBeInTheDocument() - expect(screen.getByText('/slash')).toBeInTheDocument() - expect(screen.getByText('Frontmatter description')).toBeInTheDocument() - expect(screen.getByText('Read, Edit')).toBeInTheDocument() - expect(screen.getByText('Hello')).toBeInTheDocument() - expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() - }) - - it('returns to plugins tab when skill detail was opened from plugins', async () => { - useSkillStore.setState({ - selectedSkill: { - meta: { - name: 'telegram:access', - displayName: 'Access', - description: 'Plugin skill', - source: 'plugin', - userInvocable: true, - contentLength: 200, - hasDirectory: true, - }, - tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], - files: [ - { - path: 'SKILL.md', - content: '# Access', - body: '# Access', - language: 'markdown', - isEntry: true, - }, - ], - skillRoot: '/tmp/telegram-access', - }, - selectedSkillReturnTab: 'plugins', - }) - - render() - switchToSkillsTab() - - await act(async () => { - fireEvent.click(screen.getByText('Back to list')) - await Promise.resolve() - }) - - expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + expect(useUIStore.getState().pendingSettingsTab).toBeNull() }) }) diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.test.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.test.tsx index 04ad00c0..aca16222 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.test.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.test.tsx @@ -2,10 +2,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' -const { sessionsApiMock } = vi.hoisted(() => ({ +const { sessionsApiMock, skillsApiMock } = vi.hoisted(() => ({ sessionsApiMock: { getInspection: vi.fn(), }, + skillsApiMock: { + list: vi.fn(), + }, })) vi.mock('../../api/sessions', async (importOriginal) => { @@ -18,10 +21,17 @@ vi.mock('../../api/sessions', async (importOriginal) => { } }) +vi.mock('../../api/skills', () => ({ + skillsApi: { + list: skillsApiMock.list, + }, +})) + import { LocalSlashCommandPanel } from './LocalSlashCommandPanel' import { useSettingsStore } from '../../stores/settingsStore' -import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' +import { useTabStore, SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' +import { useSkillStore } from '../../stores/skillStore' import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions' const baseContext: SessionContextSnapshot = { @@ -76,6 +86,17 @@ describe('LocalSlashCommandPanel memory context', () => { pendingMemoryPath: null, pendingSettingsTab: null, }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + selectedSkillReturnTab: 'skills', + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: vi.fn(), + fetchSkillDetail: vi.fn().mockResolvedValue(undefined), + clearSelection: vi.fn(), + }) }) it('shows loaded memory files and opens the selected project memory in settings', async () => { @@ -142,4 +163,38 @@ describe('LocalSlashCommandPanel memory context', () => { expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID) }) }) + + it('opens skill details in the unified Skill Center', async () => { + const onClose = vi.fn() + const fetchSkillDetail = vi.fn().mockResolvedValue(undefined) + useSkillStore.setState({ fetchSkillDetail }) + skillsApiMock.list.mockResolvedValue({ + skills: [{ + name: 'ppt-generator', + displayName: 'PPT Generator', + description: 'Create slide decks.', + source: 'user', + userInvocable: true, + contentLength: 100, + hasDirectory: true, + }], + }) + + render( + , + ) + + fireEvent.click(await screen.findByText('/ppt-generator')) + + await waitFor(() => { + expect(fetchSkillDetail).toHaveBeenCalledWith('user', 'ppt-generator', '/workspace/demo', 'skills') + expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID) + }) + expect(useUIStore.getState().pendingSettingsTab).toBeNull() + expect(onClose).toHaveBeenCalled() + }) }) diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index e5498e8c..be1b786e 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -9,7 +9,7 @@ import { } from '../../api/sessions' import { useTranslation, type TranslationKey } from '../../i18n' import { useUIStore } from '../../stores/uiStore' -import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID, useTabStore } from '../../stores/tabStore' import { useMcpStore } from '../../stores/mcpStore' import { useSkillStore } from '../../stores/skillStore' import type { McpServerRecord } from '../../types/mcp' @@ -954,7 +954,6 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { const t = useTranslation() - const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail) const [skills, setSkills] = useState(null) const [error, setError] = useState(null) @@ -995,8 +994,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { key={`${skill.source}:${skill.name}`} onClick={async () => { await fetchSkillDetail(skill.source, skill.name, cwd, 'skills') - setPendingSettingsTab('skills') - useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center') onClose() }} className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]" diff --git a/desktop/src/components/skills/SkillList.test.tsx b/desktop/src/components/skills/SkillList.test.tsx new file mode 100644 index 00000000..fa279dc9 --- /dev/null +++ b/desktop/src/components/skills/SkillList.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useSkillStore } from '../../stores/skillStore' +import { SkillList } from './SkillList' + +const fetchSkills = vi.fn() +const fetchSkillDetail = vi.fn() + +describe('SkillList', () => { + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Active session', + createdAt: '2026-04-20T00:00:00.000Z', + modifiedAt: '2026-04-20T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + selectedSkillReturnTab: 'skills', + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills, + fetchSkillDetail, + clearSelection: vi.fn(), + }) + }) + + 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, + }, + { + name: 'telegram:access', + displayName: 'Telegram Access', + description: 'Plugin-provided access workflow', + source: 'plugin', + pluginName: 'telegram', + userInvocable: true, + contentLength: 280, + hasDirectory: true, + }, + ], + }) + + render() + + 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() + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + expect(screen.getByText('Telegram Access')).toBeInTheDocument() + }) + + it('filters installed skills locally by keyword and clears the search', () => { + useSkillStore.setState({ + skills: [ + { + name: 'alpha', + displayName: 'Alpha Skill', + description: 'First skill description', + source: 'user', + userInvocable: true, + contentLength: 400, + hasDirectory: true, + }, + { + name: 'telegram:access', + displayName: 'Telegram Access', + description: 'Plugin-provided access workflow', + source: 'plugin', + pluginName: 'telegram', + userInvocable: true, + contentLength: 280, + hasDirectory: true, + }, + ], + }) + + render() + + const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...') + fireEvent.change(searchInput, { target: { value: 'telegram' } }) + + expect(screen.getByText('Telegram Access')).toBeInTheDocument() + expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument() + expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Clear skill search')) + + expect(screen.getByText('Telegram Access')).toBeInTheDocument() + expect(screen.getByText('Alpha Skill')).toBeInTheDocument() + }) + + it('uses the active session workDir for project-scoped skills', () => { + render() + + expect(fetchSkills).toHaveBeenCalledWith('/workspace/project') + }) +}) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index a5e02eb5..55f3f03b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -713,6 +713,8 @@ 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.redirectDescription': 'Skills now live in the unified Skill Center, where marketplace and installed skills are managed together.', + 'settings.skills.openSkillCenter': 'Open Skill Center', '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.', @@ -2053,6 +2055,12 @@ export const en = { 'skillCenter.marketplace.blockedAction': 'Blocked', 'skillCenter.marketplace.openSource': 'Open source', 'skillCenter.marketplace.openUpstream': 'Open upstream', + 'skillCenter.marketplace.confirmTitle': 'Confirm skill install', + 'skillCenter.marketplace.confirmBody': 'Review the source and target before installing this skill to your local user skills directory.', + 'skillCenter.marketplace.confirmSkill': 'Skill', + 'skillCenter.marketplace.confirmTarget': 'Target', + 'skillCenter.marketplace.confirmInstall': 'Install skill', + 'skillCenter.marketplace.noRiskLabels': 'No risk signals reported', // ─── Tabs ────────────────────────────────────── 'tabs.close': 'Close', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 39a5defa..196b4efe 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -715,6 +715,8 @@ export const jp: Record = { // Settings > Skills 'settings.skills.title': 'インストール済みスキル', 'settings.skills.description': 'スキルは Claude に専門的な機能を追加します。スキルは ~/.claude/skills/ で管理します', + 'settings.skills.redirectDescription': 'スキルは統合されたスキルセンターで管理されます。マーケットとインストール済みスキルを同じ場所で扱えます。', + 'settings.skills.openSkillCenter': 'スキルセンターを開く', 'settings.skills.browserTitle': 'インストール済みスキルを閲覧', 'settings.skills.browserEyebrow': 'スキルブラウザ', 'settings.skills.browserDescription': 'バンドル、プロジェクト、ユーザーのスキルを確認し、スコープを比較し、各スキルフォルダを開いてドキュメントやソースファイルを読みます。', @@ -2055,6 +2057,12 @@ export const jp: Record = { 'skillCenter.marketplace.blockedAction': 'インストール不可', 'skillCenter.marketplace.openSource': 'ソースを開く', 'skillCenter.marketplace.openUpstream': '上流を開く', + 'skillCenter.marketplace.confirmTitle': 'スキルのインストールを確認', + 'skillCenter.marketplace.confirmBody': 'インストール前にソースと対象ディレクトリを確認してください。スキルはローカルのユーザースキルディレクトリに書き込まれます。', + 'skillCenter.marketplace.confirmSkill': 'スキル', + 'skillCenter.marketplace.confirmTarget': '対象', + 'skillCenter.marketplace.confirmInstall': 'スキルをインストール', + 'skillCenter.marketplace.noRiskLabels': 'リスクシグナルなし', // ─── Tabs ────────────────────────────────────── 'tabs.close': '閉じる', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index b08bd34f..b05a786b 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -715,6 +715,8 @@ export const kr: Record = { // Settings > Skills 'settings.skills.title': '설치된 스킬', 'settings.skills.description': '스킬은 Claude에 전문 기능을 추가합니다. 스킬은 ~/.claude/skills/에서 관리합니다', + 'settings.skills.redirectDescription': '스킬은 이제 통합 스킬 센터에서 관리합니다. 마켓과 설치된 스킬을 한 곳에서 볼 수 있습니다.', + 'settings.skills.openSkillCenter': '스킬 센터 열기', 'settings.skills.browserTitle': '설치된 스킬 살펴보기', 'settings.skills.browserEyebrow': '스킬 브라우저', 'settings.skills.browserDescription': '번들, 프로젝트, 사용자 스킬을 확인하고 범위를 비교하며, 각 스킬 폴더를 열어 문서와 소스 파일을 읽습니다.', @@ -2055,6 +2057,12 @@ export const kr: Record = { 'skillCenter.marketplace.blockedAction': '설치 불가', 'skillCenter.marketplace.openSource': '소스 열기', 'skillCenter.marketplace.openUpstream': '업스트림 열기', + 'skillCenter.marketplace.confirmTitle': '스킬 설치 확인', + 'skillCenter.marketplace.confirmBody': '설치 전에 소스와 대상 디렉터리를 확인하세요. 스킬은 로컬 사용자 스킬 디렉터리에 기록됩니다.', + 'skillCenter.marketplace.confirmSkill': '스킬', + 'skillCenter.marketplace.confirmTarget': '대상', + 'skillCenter.marketplace.confirmInstall': '스킬 설치', + 'skillCenter.marketplace.noRiskLabels': '보고된 위험 신호 없음', // ─── Tabs ────────────────────────────────────── 'tabs.close': '닫기', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 3964fd1e..5ce23707 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -715,6 +715,8 @@ export const zh: Record = { // Settings > Skills 'settings.skills.title': '已安裝技能', 'settings.skills.description': '技能擴充套件 Claude 的能力。在 ~/.claude/skills/ 中管理技能。', + 'settings.skills.redirectDescription': '技能現在統一放在技能中心管理,市場和已安裝技能會放在同一個入口裡。', + 'settings.skills.openSkillCenter': '開啟技能中心', 'settings.skills.browserTitle': '瀏覽已安裝技能', 'settings.skills.browserEyebrow': '技能瀏覽器', 'settings.skills.browserDescription': '檢視內建、專案和使用者技能,比較它們的來源與規模,並開啟技能目錄閱讀文件和原始碼檔案。', @@ -2055,6 +2057,12 @@ export const zh: Record = { 'skillCenter.marketplace.blockedAction': '不可安裝', 'skillCenter.marketplace.openSource': '開啟來源', 'skillCenter.marketplace.openUpstream': '開啟上游', + 'skillCenter.marketplace.confirmTitle': '確認安裝技能', + 'skillCenter.marketplace.confirmBody': '安裝前請確認來源和目標目錄。技能會寫入本機使用者技能目錄。', + 'skillCenter.marketplace.confirmSkill': '技能', + 'skillCenter.marketplace.confirmTarget': '目標', + 'skillCenter.marketplace.confirmInstall': '安裝技能', + 'skillCenter.marketplace.noRiskLabels': '未回報風險訊號', // ─── Tabs ────────────────────────────────────── 'tabs.close': '關閉', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index dc401db9..92d9e4ec 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -715,6 +715,8 @@ export const zh: Record = { // Settings > Skills 'settings.skills.title': '已安装技能', 'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。', + 'settings.skills.redirectDescription': '技能现在统一放在技能中心管理,市场和已安装技能会放在同一个入口里。', + 'settings.skills.openSkillCenter': '打开技能中心', 'settings.skills.browserTitle': '浏览已安装技能', 'settings.skills.browserEyebrow': '技能浏览器', 'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。', @@ -2055,6 +2057,12 @@ export const zh: Record = { 'skillCenter.marketplace.blockedAction': '不可安装', 'skillCenter.marketplace.openSource': '打开来源', 'skillCenter.marketplace.openUpstream': '打开上游', + 'skillCenter.marketplace.confirmTitle': '确认安装技能', + 'skillCenter.marketplace.confirmBody': '安装前请确认来源和目标目录。技能会写入本机用户技能目录。', + 'skillCenter.marketplace.confirmSkill': '技能', + 'skillCenter.marketplace.confirmTarget': '目标', + 'skillCenter.marketplace.confirmInstall': '安装技能', + 'skillCenter.marketplace.noRiskLabels': '未报告风险信号', // ─── Tabs ────────────────────────────────────── 'tabs.close': '关闭', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 1b2cea19..7c2c515c 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -36,9 +36,6 @@ import { useAgentStore } from '../stores/agentStore' import { useSessionStore } from '../stores/sessionStore' import type { AgentDefinition, AgentSource } from '../api/agents' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' -import { useSkillStore } from '../stores/skillStore' -import { SkillList } from '../components/skills/SkillList' -import { SkillDetail } from '../components/skills/SkillDetail' import { usePluginStore } from '../stores/pluginStore' import { PluginList } from '../components/plugins/PluginList' import { PluginDetail } from '../components/plugins/PluginDetail' @@ -78,6 +75,7 @@ import { stripProviderSettingsJsonEnv, } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' +import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore' const NETWORK_TIMEOUT_MIN_SECONDS = 30 const NETWORK_TIMEOUT_MAX_SECONDS = 1800 @@ -207,6 +205,11 @@ export function Settings() { useUIStore.getState().setPendingSettingsTab(null) }, [pendingSettingsTab, setActiveTab]) + useEffect(() => { + if (activeTab !== 'skills') return + useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center') + }, [activeTab, t]) + return (
@@ -220,7 +223,12 @@ export function Settings() { setActiveTab('terminal')} /> setActiveTab('mcp')} /> setActiveTab('agents')} /> - setActiveTab('skills')} /> + useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')} + /> setActiveTab('memory')} /> setActiveTab('plugins')} /> setActiveTab('computerUse')} /> @@ -4321,26 +4329,24 @@ function DetailStat({ // ─── Skill Settings ────────────────────────────────────── function SkillSettings() { - const selectedSkill = useSkillStore((s) => s.selectedSkill) const t = useTranslation() - if (selectedSkill) { - return ( -
- -
- ) - } - return ( -
+

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

- {t('settings.skills.description')} + {t('settings.skills.redirectDescription')}

- +
) } diff --git a/desktop/src/pages/SkillCenter.test.tsx b/desktop/src/pages/SkillCenter.test.tsx index 1588b426..04526f76 100644 --- a/desktop/src/pages/SkillCenter.test.tsx +++ b/desktop/src/pages/SkillCenter.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { skillMarketApi } from '../api/skillMarket' import { useSettingsStore } from '../stores/settingsStore' import { useSkillMarketStore } from '../stores/skillMarketStore' +import { useSkillStore } from '../stores/skillStore' import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket' import { SkillCenter } from './SkillCenter' @@ -19,6 +20,16 @@ vi.mock('../components/skills/SkillList', () => ({ SkillList: () =>
Installed skills
, })) +vi.mock('../components/markdown/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +vi.mock('../components/chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + const mockedSkillMarketApi = vi.mocked(skillMarketApi) function makeItem(overrides: Partial = {}): SkillMarketItem { @@ -66,6 +77,17 @@ describe('SkillCenter', () => { isInstalling: false, error: null, }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + selectedSkillReturnTab: 'skills', + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: vi.fn(), + fetchSkillDetail: vi.fn(), + clearSelection: vi.fn(() => useSkillStore.setState({ selectedSkill: null })), + }) mockedSkillMarketApi.list.mockResolvedValue({ items: [makeItem()], nextCursor: null, @@ -95,6 +117,14 @@ describe('SkillCenter', () => { isInstalling: false, error: null, }) + useSkillStore.setState({ + skills: [], + selectedSkill: null, + selectedSkillReturnTab: 'skills', + isLoading: false, + isDetailLoading: false, + error: null, + }) }) it('loads marketplace cards and opens a detail confirmation panel', async () => { @@ -116,6 +146,16 @@ describe('SkillCenter', () => { expect(detail).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled() expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator') + + fireEvent.click(screen.getByRole('button', { name: 'Install' })) + expect(mockedSkillMarketApi.install).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: 'Confirm skill install' })).toBeInTheDocument() + expect(screen.getByText('~/.claude/skills/ppt-generator')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) + await waitFor(() => { + expect(mockedSkillMarketApi.install).toHaveBeenCalledWith('clawhub', 'ppt-generator', '1.0.0') + }) }) it('submits market searches without auto-searching every keystroke', async () => { @@ -166,4 +206,37 @@ describe('SkillCenter', () => { expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument() expect(within(screen.getByTestId('skill-mine-tab')).getByText('Installed skills')).toBeInTheDocument() }) + + it('opens the mine tab when an installed skill detail is already selected', async () => { + useSkillStore.setState({ + selectedSkill: { + meta: { + name: 'local-ppt', + displayName: 'Local PPT', + description: 'Installed local skill', + source: 'user', + userInvocable: true, + contentLength: 80, + hasDirectory: true, + }, + tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], + files: [ + { + path: 'SKILL.md', + content: '# Local PPT', + body: '# Local PPT', + language: 'markdown', + isEntry: true, + }, + ], + skillRoot: '/tmp/local-ppt', + }, + }) + + render() + + expect(await screen.findByText('Local PPT')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true') + expect(mockedSkillMarketApi.list).not.toHaveBeenCalled() + }) }) diff --git a/desktop/src/pages/SkillCenter.tsx b/desktop/src/pages/SkillCenter.tsx index 46e54372..21b230fb 100644 --- a/desktop/src/pages/SkillCenter.tsx +++ b/desktop/src/pages/SkillCenter.tsx @@ -12,8 +12,11 @@ import { X, } from 'lucide-react' import { SkillList } from '../components/skills/SkillList' +import { SkillDetail } from '../components/skills/SkillDetail' +import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { useTranslation } from '../i18n' import { useSkillMarketStore } from '../stores/skillMarketStore' +import { useSkillStore } from '../stores/skillStore' import type { SkillMarketDetail, SkillMarketInstallEligibility, @@ -31,7 +34,11 @@ const TRUST_SAFE: SkillMarketTrustState[] = ['clean', 'benign', 'signed', 'offic export function SkillCenter() { const t = useTranslation() - const [activeTab, setActiveTab] = useState('marketplace') + const selectedInstalledSkill = useSkillStore((s) => s.selectedSkill) + const [activeTab, setActiveTab] = useState(() => + selectedInstalledSkill ? 'mine' : 'marketplace' + ) + const [pendingInstallDetail, setPendingInstallDetail] = useState(null) const { items, selectedDetail, @@ -52,8 +59,15 @@ export function SkillCenter() { } = useSkillMarketStore() useEffect(() => { + if (activeTab !== 'marketplace') return void fetchItems() - }, [fetchItems, source, sort]) + }, [activeTab, fetchItems, source, sort]) + + useEffect(() => { + if (selectedInstalledSkill) { + setActiveTab('mine') + } + }, [selectedInstalledSkill]) const installedCount = useMemo( () => items.filter((item) => item.installed).length, @@ -215,14 +229,28 @@ export function SkillCenter() { detail={selectedDetail} loading={isDetailLoading} installing={isInstalling} - onInstall={() => void installSelected()} + onInstall={() => selectedDetail && setPendingInstallDetail(selectedDetail)} onClose={clearDetail} />
+ setPendingInstallDetail(null)} + title={t('skillCenter.marketplace.confirmTitle')} + body={pendingInstallDetail ? : null} + confirmLabel={t('skillCenter.marketplace.confirmInstall')} + cancelLabel={t('common.cancel')} + confirmVariant="primary" + loading={isInstalling} + onConfirm={async () => { + await installSelected() + setPendingInstallDetail(null) + }} + /> ) : (
- + {selectedInstalledSkill ? : }
)} @@ -230,6 +258,35 @@ export function SkillCenter() { ) } +function InstallConfirmBody({ detail }: { detail: SkillMarketDetail }) { + const t = useTranslation() + const target = `~/.claude/skills/${detail.slug}` + const riskLabel = detail.riskLabels.length > 0 + ? detail.riskLabels.map((label) => t(`skillCenter.marketplace.risk.${label}`)).join(', ') + : t('skillCenter.marketplace.noRiskLabels') + + return ( +
+

{t('skillCenter.marketplace.confirmBody')}

+
+ + + + +
+
+ ) +} + +function ConfirmRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + function SkillMarketCard({ item, active, diff --git a/desktop/src/stores/skillMarketStore.test.ts b/desktop/src/stores/skillMarketStore.test.ts index e0fe375a..af294b80 100644 --- a/desktop/src/stores/skillMarketStore.test.ts +++ b/desktop/src/stores/skillMarketStore.test.ts @@ -90,13 +90,19 @@ describe('skillMarketStore', () => { }) it('updates source, sort, and query filters', () => { + useSkillMarketStore.setState({ selectedDetail: makeDetail() }) + useSkillMarketStore.getState().setSource('clawhub') + expect(useSkillMarketStore.getState().selectedDetail).toBeNull() + + useSkillMarketStore.setState({ selectedDetail: makeDetail() }) useSkillMarketStore.getState().setSort('updated') useSkillMarketStore.getState().setQuery('security') expect(useSkillMarketStore.getState().source).toBe('clawhub') expect(useSkillMarketStore.getState().sort).toBe('updated') expect(useSkillMarketStore.getState().query).toBe('security') + expect(useSkillMarketStore.getState().selectedDetail).toBeNull() }) it('loads selected marketplace detail', async () => { @@ -111,6 +117,31 @@ describe('skillMarketStore', () => { expect(useSkillMarketStore.getState().error).toBeNull() }) + it('ignores stale marketplace detail responses', async () => { + let resolveFirst: ((value: { detail: SkillMarketDetail }) => void) | undefined + let resolveSecond: ((value: { detail: SkillMarketDetail }) => void) | undefined + const first = makeDetail({ slug: 'first-skill', displayName: 'First Skill' }) + const second = makeDetail({ + source: 'skillhub', + slug: 'second-skill', + displayName: 'Second Skill', + }) + mockedSkillMarketApi.detail + .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve })) + .mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve })) + + const firstRequest = useSkillMarketStore.getState().fetchDetail('clawhub', 'first-skill') + const secondRequest = useSkillMarketStore.getState().fetchDetail('skillhub', 'second-skill') + + resolveSecond?.({ detail: second }) + await secondRequest + expect(useSkillMarketStore.getState().selectedDetail).toEqual(second) + + resolveFirst?.({ detail: first }) + await firstRequest + expect(useSkillMarketStore.getState().selectedDetail).toEqual(second) + }) + it('installs selected detail and refreshes the list and detail', async () => { const selected = makeDetail({ version: '1.0.0' }) const refreshed = makeDetail({ diff --git a/desktop/src/stores/skillMarketStore.ts b/desktop/src/stores/skillMarketStore.ts index 821b303c..b31d4fbe 100644 --- a/desktop/src/stores/skillMarketStore.ts +++ b/desktop/src/stores/skillMarketStore.ts @@ -27,6 +27,8 @@ type SkillMarketStore = { clearDetail: () => void } +let detailRequestSeq = 0 + export const useSkillMarketStore = create((set, get) => ({ items: [], selectedDetail: null, @@ -38,13 +40,20 @@ export const useSkillMarketStore = create((set, get) => ({ isInstalling: false, error: null, - setSource: (source) => set({ source }), - setSort: (sort) => set({ sort }), + setSource: (source) => { + detailRequestSeq += 1 + set({ source, selectedDetail: null, isDetailLoading: false }) + }, + setSort: (sort) => { + detailRequestSeq += 1 + set({ sort, selectedDetail: null, isDetailLoading: false }) + }, setQuery: (query) => set({ query }), fetchItems: async () => { const { source, sort, query } = get() - set({ isLoading: true, error: null }) + detailRequestSeq += 1 + set({ isLoading: true, isDetailLoading: false, selectedDetail: null, error: null }) try { const result = await skillMarketApi.list({ source, @@ -61,11 +70,15 @@ export const useSkillMarketStore = create((set, get) => ({ }, fetchDetail: async (source, slug) => { - set({ isDetailLoading: true, error: null }) + const requestId = detailRequestSeq + 1 + detailRequestSeq = requestId + set({ isDetailLoading: true, selectedDetail: null, error: null }) try { const { detail } = await skillMarketApi.detail(source, slug) + if (requestId !== detailRequestSeq) return set({ selectedDetail: detail, isDetailLoading: false }) } catch (err) { + if (requestId !== detailRequestSeq) return set({ isDetailLoading: false, error: getErrorMessage(err), @@ -91,7 +104,10 @@ export const useSkillMarketStore = create((set, get) => ({ } }, - clearDetail: () => set({ selectedDetail: null }), + clearDetail: () => { + detailRequestSeq += 1 + set({ selectedDetail: null, isDetailLoading: false }) + }, })) function getErrorMessage(err: unknown): string { diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index f962af64..310356c2 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -700,6 +700,28 @@ describe('skill market service source selection', () => { }) }) + it('blocks uninstalled detail installs when only list-level ClawHub metadata is available', async () => { + const service = createSkillMarketService({ + fetchImpl: async () => Response.json(CLAWHUB_TOP_SKILLS_RESPONSE), + installedSkillNames: new Set(), + }) + + const detail = await service.getDetail({ source: 'clawhub', slug: 'skill-vetter' }) + + expect(detail).toMatchObject({ + source: 'clawhub', + slug: 'skill-vetter', + trustState: 'clean', + installed: false, + files: [], + riskLabels: [], + installEligibility: { + status: 'blocked', + reason: expect.stringContaining('Full package safety scan'), + }, + }) + }) + it('blocks detail installs when list trust metadata is not installable', async () => { const service = createSkillMarketService({ fetchImpl: async () => Response.json(SKILLHUB_TOP_SKILLS_RESPONSE), diff --git a/src/server/services/skillMarket/service.ts b/src/server/services/skillMarket/service.ts index be880755..0e34360a 100644 --- a/src/server/services/skillMarket/service.ts +++ b/src/server/services/skillMarket/service.ts @@ -5,7 +5,6 @@ import type { SkillMarketItem, SkillMarketListResult, SkillMarketSource, - SkillMarketTrustState, } from './types.js' export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub' @@ -44,13 +43,6 @@ const DEFAULT_LIMIT = 24 const MAX_LIMIT = 100 const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000 const FAILURE_CACHE_TTL_MS = 60 * 1_000 -const DETAIL_INSTALLABLE_TRUST_STATES = new Set([ - 'clean', - 'benign', - 'signed', - 'official', -]) - type CatalogCacheEntry = { expiresAt: number result: SkillMarketListResult @@ -242,8 +234,13 @@ function installEligibilityFromListItem(item: SkillMarketItem): SkillMarketDetai installedSkillName: item.slug, } } - if (DETAIL_INSTALLABLE_TRUST_STATES.has(item.trustState)) { - return { status: 'installable' } + if ( + item.trustState === 'clean' || + item.trustState === 'benign' || + item.trustState === 'signed' || + item.trustState === 'official' + ) { + return { status: 'blocked', reason: 'Full package safety scan is required before install.' } } if (item.trustState === 'warning') { return { status: 'blocked', reason: 'Skill market trust metadata contains warnings.' }