diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx
index 6f068a2d..abae1a37 100644
--- a/desktop/src/components/layout/ContentRouter.test.tsx
+++ b/desktop/src/components/layout/ContentRouter.test.tsx
@@ -26,6 +26,10 @@ vi.mock('../../pages/Settings', () => ({
Settings: () =>
,
}))
+vi.mock('../../pages/SkillCenter', () => ({
+ SkillCenter: () => ,
+}))
+
vi.mock('../../pages/TerminalSettings', () => ({
TerminalSettings: ({ active, cwd, onNewTerminal, runtimeId, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; runtimeId?: string; testId: string }) => (
@@ -55,7 +59,7 @@ vi.mock('../workbench/WorkbenchTab', () => ({
}))
import { ContentRouter } from './ContentRouter'
-import { useTabStore } from '../../stores/tabStore'
+import { SKILL_CENTER_TAB_ID, useTabStore } from '../../stores/tabStore'
describe('ContentRouter tab surfaces', () => {
afterEach(() => {
@@ -196,6 +200,23 @@ describe('ContentRouter tab surfaces', () => {
expect(screen.queryByTestId('subagent-run-page')).not.toBeInTheDocument()
})
+ it('renders the skill center tab without mounting the chat session surface', () => {
+ useTabStore.setState({
+ tabs: [{
+ sessionId: SKILL_CENTER_TAB_ID,
+ title: 'Skills',
+ type: 'skill-center',
+ status: 'idle',
+ }],
+ activeTabId: SKILL_CENTER_TAB_ID,
+ })
+
+ render()
+
+ expect(screen.getByTestId('skill-center-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
+ })
+
it('renders workbench tabs as main content instead of mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx
index 71ca31bd..30b05f05 100644
--- a/desktop/src/components/layout/ContentRouter.tsx
+++ b/desktop/src/components/layout/ContentRouter.tsx
@@ -4,6 +4,7 @@ import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings'
+import { SkillCenter } from '../../pages/SkillCenter'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
@@ -29,6 +30,8 @@ export function ContentRouter() {
page =
} else if (activeTabType === 'scheduled') {
page =
+ } else if (activeTabType === 'skill-center') {
+ page =
} else if (activeTabType === 'trace') {
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
page = traceSessionId ? :
diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx
index 9a692dd5..98d3f936 100644
--- a/desktop/src/components/layout/Sidebar.tsx
+++ b/desktop/src/components/layout/Sidebar.tsx
@@ -1,12 +1,12 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
-import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
+import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, Sparkles, SquarePen, X } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation, type TranslationKey } from '../../i18n'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { GlobalSearchModal } from '../search/GlobalSearchModal'
import type { SessionListItem } from '../../types/session'
-import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
+import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, SKILL_CENTER_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
@@ -687,6 +687,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.scheduled')}
)}
+ {
+ useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('sidebar.skills'), 'skill-center')
+ closeMobileDrawer()
+ }}
+ icon={}
+ >
+ {t('sidebar.skills')}
+
{expanded ? (
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index ba090de9..f2033442 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -22,6 +22,7 @@ export const en = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
'sidebar.scheduled': 'Scheduled',
+ 'sidebar.skills': 'Skills',
'sidebar.terminal': 'Terminal',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions...',
@@ -1998,6 +1999,12 @@ export const en = {
'chat.fallback.title': 'Network hiccup — switched to non-streaming mode',
'chat.fallback.detail': 'response arrives in one piece, this can take a while',
+ // ─── Skill Center ──────────────────────────────────────
+ 'skillCenter.title': 'Skills',
+ 'skillCenter.tab.marketplace': 'Marketplace',
+ 'skillCenter.tab.mine': 'Mine',
+ 'skillCenter.marketplace.loading': 'Loading skill marketplace...',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 3d8782d5..bee63fbf 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -24,6 +24,7 @@ export const jp: Record = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新しいセッション',
'sidebar.scheduled': 'スケジュール',
+ 'sidebar.skills': 'スキル',
'sidebar.terminal': 'ターミナル',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': 'セッションを検索...',
@@ -2000,6 +2001,12 @@ export const jp: Record = {
'chat.fallback.title': 'ネットワーク不調のため非ストリーミングに切替',
'chat.fallback.detail': '応答は一括で届くため時間がかかることがあります',
+ // ─── Skill Center ──────────────────────────────────────
+ 'skillCenter.title': 'スキル',
+ 'skillCenter.tab.marketplace': 'マーケット',
+ 'skillCenter.tab.mine': '自分のスキル',
+ 'skillCenter.marketplace.loading': 'スキルマーケットを読み込み中...',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',
'tabs.closeOthers': '他を閉じる',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 182881d5..64106138 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -24,6 +24,7 @@ export const kr: Record = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '새 세션',
'sidebar.scheduled': '예약 작업',
+ 'sidebar.skills': '스킬',
'sidebar.terminal': '터미널',
'sidebar.settings': '설정',
'sidebar.searchPlaceholder': '세션 검색...',
@@ -2000,6 +2001,12 @@ export const kr: Record = {
'chat.fallback.title': '네트워크 불안정으로 비스트리밍 모드로 전환됨',
'chat.fallback.detail': '응답이 한 번에 도착하므로 시간이 걸릴 수 있습니다',
+ // ─── Skill Center ──────────────────────────────────────
+ 'skillCenter.title': '스킬',
+ 'skillCenter.tab.marketplace': '마켓',
+ 'skillCenter.tab.mine': '내 스킬',
+ 'skillCenter.marketplace.loading': '스킬 마켓을 불러오는 중...',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',
'tabs.closeOthers': '다른 탭 닫기',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index e1197a84..5eba3ba9 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -24,6 +24,7 @@ export const zh: Record = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建會話',
'sidebar.scheduled': '定時任務',
+ 'sidebar.skills': '技能',
'sidebar.terminal': '終端',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': '搜尋會話...',
@@ -2000,6 +2001,12 @@ export const zh: Record = {
'chat.fallback.title': '網路波動,已切換為非串流請求',
'chat.fallback.detail': '回應將一次性返回,可能需要較長時間',
+ // ─── Skill Center ──────────────────────────────────────
+ 'skillCenter.title': '技能',
+ 'skillCenter.tab.marketplace': '市場',
+ 'skillCenter.tab.mine': '我的',
+ 'skillCenter.marketplace.loading': '正在載入技能市場...',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',
'tabs.closeOthers': '關閉其他',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 4cc5a51a..55508478 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -24,6 +24,7 @@ export const zh: Record = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
'sidebar.scheduled': '定时任务',
+ 'sidebar.skills': '技能',
'sidebar.terminal': '终端',
'sidebar.settings': '设置',
'sidebar.searchPlaceholder': '搜索会话...',
@@ -2000,6 +2001,12 @@ export const zh: Record = {
'chat.fallback.title': '网络波动,已切换为非流式请求',
'chat.fallback.detail': '响应将一次性返回,可能需要较长时间',
+ // ─── Skill Center ──────────────────────────────────────
+ 'skillCenter.title': '技能',
+ 'skillCenter.tab.marketplace': '市场',
+ 'skillCenter.tab.mine': '我的',
+ 'skillCenter.marketplace.loading': '正在加载技能市场...',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他',
diff --git a/desktop/src/pages/SkillCenter.tsx b/desktop/src/pages/SkillCenter.tsx
new file mode 100644
index 00000000..39c4bb6d
--- /dev/null
+++ b/desktop/src/pages/SkillCenter.tsx
@@ -0,0 +1,70 @@
+import { useState } from 'react'
+import { SkillList } from '../components/skills/SkillList'
+import { useTranslation } from '../i18n'
+
+type SkillCenterTab = 'marketplace' | 'mine'
+
+export function SkillCenter() {
+ const t = useTranslation()
+ const [activeTab, setActiveTab] = useState('marketplace')
+
+ return (
+
+
+
+
+ {activeTab === 'marketplace' ? (
+
+ {t('skillCenter.marketplace.loading')}
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+
+function tabClass(active: boolean) {
+ return [
+ 'min-w-[6rem] rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
+ active
+ ? 'bg-[var(--color-surface)] text-[var(--color-text-primary)] shadow-sm'
+ : 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]',
+ ].join(' ')
+}
diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts
index 19fe8f0d..ea8e637d 100644
--- a/desktop/src/stores/tabStore.test.ts
+++ b/desktop/src/stores/tabStore.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionsApi } from '../api/sessions'
-import { SETTINGS_TAB_ID, useTabStore } from './tabStore'
+import { SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID, useTabStore } from './tabStore'
vi.mock('../api/sessions', () => ({
sessionsApi: {
@@ -114,4 +114,23 @@ describe('tabStore', () => {
},
])
})
+
+ it('restores the skill center tab without requiring a server session', async () => {
+ localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
+ openTabs: [{ sessionId: SKILL_CENTER_TAB_ID, title: 'Skills', type: 'skill-center' }],
+ activeTabId: SKILL_CENTER_TAB_ID,
+ }))
+
+ await useTabStore.getState().restoreTabs()
+
+ expect(useTabStore.getState().tabs).toEqual([
+ {
+ sessionId: SKILL_CENTER_TAB_ID,
+ title: 'Skills',
+ type: 'skill-center',
+ status: 'idle',
+ },
+ ])
+ expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
+ })
})
diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts
index 2bfa5216..05492518 100644
--- a/desktop/src/stores/tabStore.ts
+++ b/desktop/src/stores/tabStore.ts
@@ -7,13 +7,14 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export const SETTINGS_TAB_ID = '__settings__'
export const SCHEDULED_TAB_ID = '__scheduled__'
+export const SKILL_CENTER_TAB_ID = '__skill_center__'
export const TRACE_LIST_TAB_ID = '__traces__'
export const TERMINAL_TAB_PREFIX = '__terminal__'
export const TRACE_TAB_PREFIX = '__trace__'
export const WORKBENCH_TAB_PREFIX = '__workbench__'
export const SUBAGENT_TAB_PREFIX = '__subagent__'
-export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent'
+export type TabType = 'session' | 'settings' | 'scheduled' | 'skill-center' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent'
export type Tab = {
sessionId: string
@@ -310,14 +311,14 @@ export const useTabStore = create((set, get) => ({
const validTabs: Tab[] = data.openTabs
.filter((t) => {
// Special tabs are always valid
- if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') return true
+ if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'skill-center' || t.type === 'traces') return true
if (t.type === 'trace') return !!t.traceSessionId && existingIds.has(t.traceSessionId)
if (t.type === 'terminal') return false
// Session tabs must exist on server
return existingIds.has(t.sessionId)
})
.map((t) => {
- if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') {
+ if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'skill-center' || t.type === 'traces') {
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
}
if (t.type === 'trace' && t.traceSessionId) {