feat: add skill center route

Tested: cd desktop && bun run test -- ContentRouter.test.tsx

Tested: cd desktop && bun run test -- tabStore.test.ts

Tested: cd desktop && bun run lint

Tested: git diff --check

Scope-risk: narrow

Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:05:23 +08:00
parent df00c65fb2
commit c48eeb5c4e
11 changed files with 169 additions and 7 deletions

View File

@ -26,6 +26,10 @@ vi.mock('../../pages/Settings', () => ({
Settings: () => <div data-testid="settings-page" />,
}))
vi.mock('../../pages/SkillCenter', () => ({
SkillCenter: () => <div data-testid="skill-center-page" />,
}))
vi.mock('../../pages/TerminalSettings', () => ({
TerminalSettings: ({ active, cwd, onNewTerminal, runtimeId, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; runtimeId?: string; testId: string }) => (
<div data-active={active ? 'true' : 'false'} data-cwd={cwd ?? ''} data-runtime-id={runtimeId ?? ''} data-testid={testId}>
@ -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(<ContentRouter />)
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: [{

View File

@ -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 = <Settings />
} else if (activeTabType === 'scheduled') {
page = <ScheduledTasks />
} else if (activeTabType === 'skill-center') {
page = <SkillCenter />
} else if (activeTabType === 'trace') {
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />

View File

@ -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')}
</NavItem>
)}
<NavItem
active={activeTabId === SKILL_CENTER_TAB_ID}
collapsed={!expanded}
label={t('sidebar.skills')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('sidebar.skills'), 'skill-center')
closeMobileDrawer()
}}
icon={<Sparkles className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />}
>
{t('sidebar.skills')}
</NavItem>
</div>
{expanded ? (

View File

@ -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',

View File

@ -24,6 +24,7 @@ export const jp: Record<TranslationKey, string> = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新しいセッション',
'sidebar.scheduled': 'スケジュール',
'sidebar.skills': 'スキル',
'sidebar.terminal': 'ターミナル',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': 'セッションを検索...',
@ -2000,6 +2001,12 @@ export const jp: Record<TranslationKey, string> = {
'chat.fallback.title': 'ネットワーク不調のため非ストリーミングに切替',
'chat.fallback.detail': '応答は一括で届くため時間がかかることがあります',
// ─── Skill Center ──────────────────────────────────────
'skillCenter.title': 'スキル',
'skillCenter.tab.marketplace': 'マーケット',
'skillCenter.tab.mine': '自分のスキル',
'skillCenter.marketplace.loading': 'スキルマーケットを読み込み中...',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',
'tabs.closeOthers': '他を閉じる',

View File

@ -24,6 +24,7 @@ export const kr: Record<TranslationKey, string> = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '새 세션',
'sidebar.scheduled': '예약 작업',
'sidebar.skills': '스킬',
'sidebar.terminal': '터미널',
'sidebar.settings': '설정',
'sidebar.searchPlaceholder': '세션 검색...',
@ -2000,6 +2001,12 @@ export const kr: Record<TranslationKey, string> = {
'chat.fallback.title': '네트워크 불안정으로 비스트리밍 모드로 전환됨',
'chat.fallback.detail': '응답이 한 번에 도착하므로 시간이 걸릴 수 있습니다',
// ─── Skill Center ──────────────────────────────────────
'skillCenter.title': '스킬',
'skillCenter.tab.marketplace': '마켓',
'skillCenter.tab.mine': '내 스킬',
'skillCenter.marketplace.loading': '스킬 마켓을 불러오는 중...',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',
'tabs.closeOthers': '다른 탭 닫기',

View File

@ -24,6 +24,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建會話',
'sidebar.scheduled': '定時任務',
'sidebar.skills': '技能',
'sidebar.terminal': '終端',
'sidebar.settings': '設定',
'sidebar.searchPlaceholder': '搜尋會話...',
@ -2000,6 +2001,12 @@ export const zh: Record<TranslationKey, string> = {
'chat.fallback.title': '網路波動,已切換為非串流請求',
'chat.fallback.detail': '回應將一次性返回,可能需要較長時間',
// ─── Skill Center ──────────────────────────────────────
'skillCenter.title': '技能',
'skillCenter.tab.marketplace': '市場',
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在載入技能市場...',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',
'tabs.closeOthers': '關閉其他',

View File

@ -24,6 +24,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
'sidebar.scheduled': '定时任务',
'sidebar.skills': '技能',
'sidebar.terminal': '终端',
'sidebar.settings': '设置',
'sidebar.searchPlaceholder': '搜索会话...',
@ -2000,6 +2001,12 @@ export const zh: Record<TranslationKey, string> = {
'chat.fallback.title': '网络波动,已切换为非流式请求',
'chat.fallback.detail': '响应将一次性返回,可能需要较长时间',
// ─── Skill Center ──────────────────────────────────────
'skillCenter.title': '技能',
'skillCenter.tab.marketplace': '市场',
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在加载技能市场...',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他',

View File

@ -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<SkillCenterTab>('marketplace')
return (
<div className="flex h-full min-h-0 flex-col bg-[var(--color-surface)]">
<header className="flex flex-none flex-col gap-4 border-b border-[var(--color-border)] px-6 py-4">
<h2 className="text-lg font-semibold tracking-normal text-[var(--color-text-primary)]">
{t('skillCenter.title')}
</h2>
<div
role="tablist"
aria-label={t('skillCenter.title')}
className="inline-flex w-fit rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-1"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'marketplace'}
className={tabClass(activeTab === 'marketplace')}
onClick={() => setActiveTab('marketplace')}
>
{t('skillCenter.tab.marketplace')}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'mine'}
className={tabClass(activeTab === 'mine')}
onClick={() => setActiveTab('mine')}
>
{t('skillCenter.tab.mine')}
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{activeTab === 'marketplace' ? (
<div
role="tabpanel"
data-testid="skill-marketplace-tab"
className="text-sm text-[var(--color-text-secondary)]"
>
{t('skillCenter.marketplace.loading')}
</div>
) : (
<div role="tabpanel" data-testid="skill-mine-tab">
<SkillList />
</div>
)}
</main>
</div>
)
}
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(' ')
}

View File

@ -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)
})
})

View File

@ -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<TabStore>((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) {