diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index d8bec08b..e2090634 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -51,6 +51,7 @@ vi.mock('../stores/agentStore', () => ({ describe('Settings > Skills compatibility entry', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() useSettingsStore.setState({ locale: 'en' }) useTabStore.setState(useTabStore.getInitialState(), true) useUIStore.setState({ @@ -59,21 +60,30 @@ describe('Settings > Skills compatibility entry', () => { }) }) - it('opens the unified Skill Center from the legacy settings tab button', () => { + it('does not keep the removed Skills entry inside Settings navigation', () => { render() - 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', - }) + expect(screen.queryByRole('button', { name: 'Skills' })).not.toBeInTheDocument() }) - it('redirects pending legacy skills settings navigation to the Skill Center', async () => { + it('normalizes a legacy active Skills settings tab back to General', async () => { + const fetchOutputStyles = vi.fn().mockResolvedValue(undefined) + useSettingsStore.setState({ fetchOutputStyles }) + useUIStore.setState({ + activeSettingsTab: 'skills', + pendingSettingsTab: null, + }) + + render() + + await waitFor(() => { + expect(useUIStore.getState().activeSettingsTab).toBe('general') + }) + expect(useTabStore.getState().activeTabId).not.toBe(SKILL_CENTER_TAB_ID) + expect(fetchOutputStyles).toHaveBeenCalled() + }) + + it('redirects pending legacy skills settings navigation to the Skill Center without persisting it', async () => { useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: 'skills', @@ -85,5 +95,7 @@ describe('Settings > Skills compatibility entry', () => { expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID) }) expect(useUIStore.getState().pendingSettingsTab).toBeNull() + expect(useUIStore.getState().activeSettingsTab).toBe('providers') + expect(localStorage.getItem('cc-haha-active-settings-tab')).not.toBe('skills') }) }) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 6bf1d156..978c23c4 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -1217,6 +1217,36 @@ describe('TabBar', () => { expect(useTabStore.getState().tabs).toEqual([]) }) + it('closes the skill center tab from the close button without disconnecting chat sessions', async () => { + const { TabBar } = await import('./TabBar') + const { SKILL_CENTER_TAB_ID, useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + const disconnectSession = vi.fn() + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + { sessionId: SKILL_CENTER_TAB_ID, title: 'Skills', type: 'skill-center', status: 'idle' }, + ], + activeTabId: SKILL_CENTER_TAB_ID, + }) + useChatStore.setState({ + sessions: {}, + disconnectSession, + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByLabelText('Close Skills')) + + expect(disconnectSession).not.toHaveBeenCalled() + expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1']) + expect(useTabStore.getState().activeTabId).toBe('tab-1') + }) + it('opens the bottom terminal panel from the toolbar for an active session', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index e77ecc33..6e998d62 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -636,9 +636,9 @@ const TabItem = forwardRef { e.stopPropagation() }} onClick={(e) => { e.stopPropagation(); onClose() }} - className="flex-shrink-0 -mr-0.5 inline-flex h-3 w-3 items-center justify-center bg-transparent p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-[opacity,color] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] focus-visible:outline-none" + className="flex-shrink-0 -mr-1 inline-flex h-6 w-6 items-center justify-center rounded-md bg-transparent p-0 opacity-0 transition-[background-color,opacity,color] text-[var(--color-text-tertiary)] group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]" > - close + close ) diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index abe9f5d6..6b464257 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '../../stores/uiStore' import { Button } from '../shared/Button' import { ConfirmDialog } from '../shared/ConfirmDialog' import type { PluginCapabilityKey } from '../../types/plugin' -import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID, useTabStore } from '../../stores/tabStore' import { useSkillStore } from '../../stores/skillStore' import { useAgentStore } from '../../stores/agentStore' import { useMcpStore } from '../../stores/mcpStore' @@ -101,7 +101,7 @@ export function PluginDetail() { } } - const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => { + const openSettingsTab = (tab: 'agents' | 'mcp') => { useUIStore.getState().setPendingSettingsTab(tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') } @@ -114,7 +114,7 @@ export function PluginDetail() { }) return } - openSettingsTab('skills') + useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center') await fetchSkillDetail('plugin', skillName, currentWorkDir, 'plugins') const { selectedSkill, error } = useSkillStore.getState() diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 2a5584bb..14f0909b 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -48,6 +48,26 @@ describe('desktop persistence migrations', () => { }) }) + test('canonicalizes mismatched persisted special tab ids and types during startup migration', () => { + window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({ + openTabs: [ + { sessionId: '__settings__', title: 'Settings', type: 'skill-center' }, + { sessionId: '__skill_center__', title: 'Skills', type: 'settings' }, + ], + activeTabId: '__settings__', + })) + + runDesktopPersistenceMigrations() + + expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs') || '{}')).toEqual({ + openTabs: [ + { sessionId: '__settings__', title: 'Settings', type: 'settings' }, + { sessionId: '__skill_center__', title: 'Skills', type: 'skill-center' }, + ], + activeTabId: '__settings__', + }) + }) + test('filters stale session runtime selections without clearing unrelated keys', () => { window.localStorage.setItem('unrelated-user-key', 'keep') window.localStorage.setItem('cc-haha-session-runtime', JSON.stringify({ diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index 70c86623..218571d4 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -21,6 +21,12 @@ const THEME_STORAGE_KEY = 'cc-haha-theme' const LOCALE_STORAGE_KEY = 'cc-haha-locale' const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'skill-center', 'traces'] as const +const PERSISTED_SPECIAL_TAB_IDS: Record<(typeof PERSISTED_SPECIAL_TAB_TYPES)[number], string> = { + settings: '__settings__', + scheduled: '__scheduled__', + 'skill-center': '__skill_center__', + traces: '__traces__', +} const SUPPORTED_LOCALES = ['en', 'zh', 'zh-TW', 'jp', 'kr'] function readJson(storage: StorageLike, key: string): unknown { @@ -37,6 +43,14 @@ function isPersistedSpecialTabType(value: unknown): value is (typeof PERSISTED_S return typeof value === 'string' && (PERSISTED_SPECIAL_TAB_TYPES as readonly string[]).includes(value) } +function getPersistedSpecialTabType(tab: Record): (typeof PERSISTED_SPECIAL_TAB_TYPES)[number] | null { + if (tab.sessionId === '__settings__') return 'settings' + if (tab.sessionId === '__scheduled__') return 'scheduled' + if (tab.sessionId === '__skill_center__') return 'skill-center' + if (tab.sessionId === '__traces__') return 'traces' + return isPersistedSpecialTabType(tab.type) ? tab.type : null +} + function writeJson(storage: StorageLike, key: string, value: unknown): void { storage.setItem(key, JSON.stringify(value)) } @@ -56,13 +70,14 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void .filter((tab): tab is Record => isRecord(tab)) .filter((tab) => typeof tab.sessionId === 'string' && typeof tab.title === 'string') .filter((tab) => tab.type !== 'terminal' && !String(tab.sessionId).startsWith('__terminal__')) - .map((tab) => ({ - sessionId: tab.sessionId as string, - title: tab.title as string, - type: isPersistedSpecialTabType(tab.type) - ? tab.type - : 'session', - })) + .map((tab) => { + const specialType = getPersistedSpecialTabType(tab) + return { + sessionId: specialType ? PERSISTED_SPECIAL_TAB_IDS[specialType] : tab.sessionId as string, + title: tab.title as string, + type: specialType ?? 'session', + } + }) const activeTabId = isRecord(parsed) && typeof parsed.activeTabId === 'string' && diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 95b8149c..685627e5 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -194,21 +194,27 @@ function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string } export function Settings() { - const activeTab = useUIStore((s) => s.activeSettingsTab) + const activeSettingsTab = useUIStore((s) => s.activeSettingsTab) + const activeTab = activeSettingsTab === 'skills' ? 'general' : activeSettingsTab const setActiveTab = useUIStore((s) => s.setActiveSettingsTab) const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) const t = useTranslation() useEffect(() => { if (!pendingSettingsTab) return + if (pendingSettingsTab === 'skills') { + useUIStore.getState().setPendingSettingsTab(null) + useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center') + return + } setActiveTab(pendingSettingsTab) useUIStore.getState().setPendingSettingsTab(null) - }, [pendingSettingsTab, setActiveTab]) + }, [pendingSettingsTab, setActiveTab, t]) useEffect(() => { - if (activeTab !== 'skills') return - useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center') - }, [activeTab, t]) + if (activeSettingsTab !== 'skills') return + setActiveTab('general') + }, [activeSettingsTab, setActiveTab]) return (
@@ -223,12 +229,6 @@ export function Settings() { setActiveTab('terminal')} /> setActiveTab('mcp')} /> setActiveTab('agents')} /> - useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')} - /> setActiveTab('memory')} /> setActiveTab('plugins')} /> setActiveTab('computerUse')} /> @@ -251,7 +251,6 @@ export function Settings() { {activeTab === 'terminal' && } {activeTab === 'mcp' && } {activeTab === 'agents' && } - {activeTab === 'skills' && } {activeTab === 'memory' && } {activeTab === 'plugins' && } {activeTab === 'computerUse' && } @@ -4326,31 +4325,6 @@ function DetailStat({
) } -// ─── Skill Settings ────────────────────────────────────── - -function SkillSettings() { - const t = useTranslation() - - return ( -
-

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

-

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

- -
- ) -} - function PluginSettings() { const selectedPlugin = usePluginStore((s) => s.selectedPlugin) const t = useTranslation() diff --git a/desktop/src/pages/SkillCenter.test.tsx b/desktop/src/pages/SkillCenter.test.tsx index d25b8f74..6ae17f37 100644 --- a/desktop/src/pages/SkillCenter.test.tsx +++ b/desktop/src/pages/SkillCenter.test.tsx @@ -263,6 +263,20 @@ describe('SkillCenter', () => { expect(detailLayer.querySelector('.skill-market-detail-scrim')).not.toBeNull() }) + it('closes the marketplace detail drawer from the close icon', async () => { + render() + fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) + + const dialog = await screen.findByRole('dialog', { name: 'PPT Generator' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Close skill details' })) + + await waitFor(() => { + expect(screen.queryByTestId('skill-market-detail-layer')).not.toBeInTheDocument() + }) + expect(useSkillMarketStore.getState().selectedDetail).toBeNull() + expect(useSkillMarketStore.getState().isDetailLoading).toBe(false) + }) + it('announces detail drawer skeleton loading without visible copy', async () => { mockedSkillMarketApi.detail.mockReturnValue(new Promise(() => {})) diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts index ea8e637d..15f2b481 100644 --- a/desktop/src/stores/tabStore.test.ts +++ b/desktop/src/stores/tabStore.test.ts @@ -28,6 +28,25 @@ describe('tabStore', () => { expect(useTabStore.getState().activeTabId).toBe('session-1') }) + it('repairs an existing special tab type when opened through its canonical entrypoint', () => { + useTabStore.setState({ + tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Skills', type: 'skill-center', status: 'idle' }], + activeTabId: SETTINGS_TAB_ID, + }) + + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + + expect(useTabStore.getState().tabs).toEqual([ + { + sessionId: SETTINGS_TAB_ID, + title: 'Settings', + type: 'settings', + status: 'idle', + }, + ]) + expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID) + }) + it('stores a promoted terminal runtime id on new terminal tabs', () => { const tabId = useTabStore.getState().openTerminalTab('/tmp/project', '__session_terminal__session-1') @@ -133,4 +152,32 @@ describe('tabStore', () => { ]) expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID) }) + + it('canonicalizes mismatched persisted special tab ids and types during restore', async () => { + localStorage.setItem('cc-haha-open-tabs', JSON.stringify({ + openTabs: [ + { sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'skill-center' }, + { sessionId: SKILL_CENTER_TAB_ID, title: 'Skills', type: 'settings' }, + ], + activeTabId: SETTINGS_TAB_ID, + })) + + await useTabStore.getState().restoreTabs() + + expect(useTabStore.getState().tabs).toEqual([ + { + sessionId: SETTINGS_TAB_ID, + title: 'Settings', + type: 'settings', + status: 'idle', + }, + { + sessionId: SKILL_CENTER_TAB_ID, + title: 'Skills', + type: 'skill-center', + status: 'idle', + }, + ]) + expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID) + }) }) diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 05492518..e513de95 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -15,6 +15,7 @@ export const WORKBENCH_TAB_PREFIX = '__workbench__' export const SUBAGENT_TAB_PREFIX = '__subagent__' export type TabType = 'session' | 'settings' | 'scheduled' | 'skill-center' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent' +type PersistentSpecialTabType = 'settings' | 'scheduled' | 'skill-center' | 'traces' export type Tab = { sessionId: string @@ -55,11 +56,29 @@ type TabStore = { restoreTabs: () => Promise } +const PERSISTENT_SPECIAL_TAB_IDS: Record = { + settings: SETTINGS_TAB_ID, + scheduled: SCHEDULED_TAB_ID, + 'skill-center': SKILL_CENTER_TAB_ID, + traces: TRACE_LIST_TAB_ID, +} + +function getPersistentSpecialTabType(tab: Pick & { type?: TabType }): PersistentSpecialTabType | null { + if (tab.sessionId === SETTINGS_TAB_ID) return 'settings' + if (tab.sessionId === SCHEDULED_TAB_ID) return 'scheduled' + if (tab.sessionId === SKILL_CENTER_TAB_ID) return 'skill-center' + if (tab.sessionId === TRACE_LIST_TAB_ID) return 'traces' + if (tab.type === 'settings' || tab.type === 'scheduled' || tab.type === 'skill-center' || tab.type === 'traces') { + return tab.type + } + return null +} + export const useTabStore = create((set, get) => ({ tabs: [], activeTabId: null, - openTab: (sessionId, title, type = 'session') => { + openTab: (sessionId, title, type) => { const { tabs } = get() const existing = tabs.find((t) => t.sessionId === sessionId) if (existing) { @@ -69,7 +88,7 @@ export const useTabStore = create((set, get) => ({ ? { ...tab, title, - ...(!(tab as Partial).type ? { type } : {}), + type: type ?? tab.type ?? 'session', } : tab, ), @@ -77,7 +96,7 @@ export const useTabStore = create((set, get) => ({ }) } else { set({ - tabs: [...tabs, { sessionId, title, type, status: 'idle' }], + tabs: [...tabs, { sessionId, title, type: type ?? 'session', status: 'idle' }], activeTabId: sessionId, }) } @@ -311,15 +330,16 @@ 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 === 'skill-center' || t.type === 'traces') return true + if (getPersistentSpecialTabType(t)) 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 === 'skill-center' || t.type === 'traces') { - return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const } + const specialType = getPersistentSpecialTabType(t) + if (specialType) { + return { sessionId: PERSISTENT_SPECIAL_TAB_IDS[specialType], title: t.title, type: specialType, status: 'idle' as const } } if (t.type === 'trace' && t.traceSessionId) { const sourceTitle = sessions.find((s) => s.id === t.traceSessionId)?.title || t.title diff --git a/desktop/src/stores/uiStore.test.ts b/desktop/src/stores/uiStore.test.ts index f58bff54..49d61a44 100644 --- a/desktop/src/stores/uiStore.test.ts +++ b/desktop/src/stores/uiStore.test.ts @@ -71,4 +71,12 @@ describe('uiStore settings tab persistence', () => { expect(useUIStore.getState().activeSettingsTab).toBe('providers') }) + + it('normalizes the removed Skills settings tab when hydrating persisted state', async () => { + window.localStorage.setItem('cc-haha-active-settings-tab', 'skills') + + const { useUIStore } = await import('./uiStore') + + expect(useUIStore.getState().activeSettingsTab).toBe('general') + }) }) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index ca303d3f..b26beda8 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -37,6 +37,7 @@ function isSettingsTab(value: unknown): value is SettingsTab { function getStoredSettingsTab(): SettingsTab { try { const stored = localStorage.getItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY) + if (stored === 'skills') return 'general' if (isSettingsTab(stored)) return stored } catch { /* localStorage unavailable */ } return 'providers' @@ -134,8 +135,9 @@ export const useUIStore = create((set) => ({ setSidebarOpen: (open) => set({ sidebarOpen: open }), setActiveView: (view) => set({ activeView: view }), setActiveSettingsTab: (tab) => { - try { localStorage.setItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY, tab) } catch { /* noop */ } - set({ activeSettingsTab: tab }) + const next = tab === 'skills' ? 'general' : tab + try { localStorage.setItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY, next) } catch { /* noop */ } + set({ activeSettingsTab: next }) }, setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }), setPendingMemoryPath: (path) => set({ pendingMemoryPath: path }),