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

+ {t('skillCenter.title')} +

+
+ + +
+
+ +
+ {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) {