mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
df00c65fb2
commit
c48eeb5c4e
@ -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: [{
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '他を閉じる',
|
||||
|
||||
@ -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': '다른 탭 닫기',
|
||||
|
||||
@ -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': '關閉其他',
|
||||
|
||||
@ -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': '关闭其他',
|
||||
|
||||
70
desktop/src/pages/SkillCenter.tsx
Normal file
70
desktop/src/pages/SkillCenter.tsx
Normal 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(' ')
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user