fix: restore settings after skill center split

Tested: cd desktop && bun run test -- skillsSettings.test.tsx tabStore.test.ts uiStore.test.ts persistenceMigrations.test.ts TabBar.test.tsx SkillCenter.test.tsx --run
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: full bun run verify was not run for this scoped desktop fix.
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-07-06 20:11:50 +08:00
parent 15b3acc85b
commit ce7b2b4292
12 changed files with 211 additions and 69 deletions

View File

@ -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(<Settings />)
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(<Settings />)
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')
})
})

View File

@ -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<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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')

View File

@ -636,9 +636,9 @@ const TabItem = forwardRef<HTMLDivElement, {
aria-label={`Close ${tab.title || 'Untitled'}`}
onMouseDown={(e) => { 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)]"
>
<span className="material-symbols-outlined text-[11px] leading-none">close</span>
<span className="material-symbols-outlined text-[13px] leading-none">close</span>
</button>
</div>
)

View File

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

View File

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

View File

@ -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<string, unknown>): (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<string, unknown> => 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' &&

View File

@ -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 (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
@ -223,12 +229,6 @@ export function Settings() {
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton
icon="auto_awesome"
label={t('settings.tab.skills')}
active={activeTab === 'skills'}
onClick={() => useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')}
/>
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
@ -251,7 +251,6 @@ export function Settings() {
{activeTab === 'terminal' && <TerminalSettings showPreferences />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'memory' && <MemorySettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
@ -4326,31 +4325,6 @@ function DetailStat({
</div>
)
}
// ─── Skill Settings ──────────────────────────────────────
function SkillSettings() {
const t = useTranslation()
return (
<div className="w-full min-w-0 max-w-2xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.skills.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.skills.redirectDescription')}
</p>
<button
type="button"
onClick={() => useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--color-brand)] px-4 text-sm font-semibold text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
{t('settings.skills.openSkillCenter')}
</button>
</div>
)
}
function PluginSettings() {
const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
const t = useTranslation()

View File

@ -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(<SkillCenter />)
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(() => {}))

View File

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

View File

@ -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<void>
}
const PERSISTENT_SPECIAL_TAB_IDS: Record<PersistentSpecialTabType, string> = {
settings: SETTINGS_TAB_ID,
scheduled: SCHEDULED_TAB_ID,
'skill-center': SKILL_CENTER_TAB_ID,
traces: TRACE_LIST_TAB_ID,
}
function getPersistentSpecialTabType(tab: Pick<Tab, 'sessionId'> & { 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<TabStore>((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<TabStore>((set, get) => ({
? {
...tab,
title,
...(!(tab as Partial<Tab>).type ? { type } : {}),
type: type ?? tab.type ?? 'session',
}
: tab,
),
@ -77,7 +96,7 @@ export const useTabStore = create<TabStore>((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<TabStore>((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

View File

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

View File

@ -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<UIStore>((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 }),