From cbe2baf5964773b99e6242d894a8d6dc7ec7dc57 Mon Sep 17 00:00:00 2001 From: Justin Gao <62669951+encyc@users.noreply.github.com> Date: Tue, 5 May 2026 22:50:25 +0800 Subject: [PATCH] feat(web): add skills & design systems management page in settings (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add skills & design systems management page in settings Add a new "Library" section in Settings that lets users browse, search, preview, and enable/disable skills and design systems. Disabled items are excluded from the create-project picker. Phase 1 — browse/toggle only. Closes #497 * fix(web): persist empty disabled lists and deduplicate DS preview Use empty array instead of undefined when all items are re-enabled so the daemon merge clears the key. Move DS preview panel outside the category group loop so it renders once, not per group. * fix(web): address review feedback on library settings Clear disabled lists on invalid daemon writes, memoize enabled item filters in App.tsx, and guard preview fetch against rapid-click race conditions. * fix(web): hydrate disabled lists from daemon and keep full lists in ProjectView Merge daemonConfig.disabledSkills/disabledDesignSystems during bootstrap so the values survive localStorage resets. Pass unfiltered skills and design systems to ProjectView so existing project metadata resolves correctly. --- apps/daemon/src/app-config.ts | 11 + apps/daemon/tests/app-config.test.ts | 43 +++ apps/web/src/App.tsx | 24 +- apps/web/src/components/LibrarySection.tsx | 369 +++++++++++++++++++++ apps/web/src/components/SettingsDialog.tsx | 17 + apps/web/src/i18n/locales/ar.ts | 13 + apps/web/src/i18n/locales/de.ts | 13 + apps/web/src/i18n/locales/en.ts | 13 + apps/web/src/i18n/locales/es-ES.ts | 13 + apps/web/src/i18n/locales/fa.ts | 13 + apps/web/src/i18n/locales/fr.ts | 13 + apps/web/src/i18n/locales/hu.ts | 13 + apps/web/src/i18n/locales/ja.ts | 13 + apps/web/src/i18n/locales/ko.ts | 13 + apps/web/src/i18n/locales/pl.ts | 13 + apps/web/src/i18n/locales/pt-BR.ts | 13 + apps/web/src/i18n/locales/ru.ts | 13 + apps/web/src/i18n/locales/tr.ts | 13 + apps/web/src/i18n/locales/uk.ts | 13 + apps/web/src/i18n/locales/zh-CN.ts | 13 + apps/web/src/i18n/locales/zh-TW.ts | 13 + apps/web/src/i18n/types.ts | 13 + apps/web/src/index.css | 263 +++++++++++++++ apps/web/src/state/config.ts | 2 + apps/web/src/types.ts | 3 + packages/contracts/src/api/app-config.ts | 2 + 26 files changed, 952 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/LibrarySection.tsx diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index 5f6152fc8..a1761f762 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -22,6 +22,8 @@ export interface AppConfigPrefs { agentModels?: Record; skillId?: string | null; designSystemId?: string | null; + disabledSkills?: string[]; + disabledDesignSystems?: string[]; } const ALLOWED_KEYS: ReadonlySet = new Set([ @@ -30,6 +32,8 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ 'agentModels', 'skillId', 'designSystemId', + 'disabledSkills', + 'disabledDesignSystems', ] as const); function configFile(dataDir: string): string { @@ -84,6 +88,13 @@ function applyConfigValue( delete target[key]; } } + if (key === 'disabledSkills' || key === 'disabledDesignSystems') { + if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { + target[key] = value; + } else { + delete target[key]; + } + } } function filterAllowedKeys(obj: Record): AppConfigPrefs { diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index b7001d287..c4a4d2179 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -216,6 +216,49 @@ function httpRequest( }); } +describe('app-config disabled lists', () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(path.join(tmpdir(), 'od-disabled-')); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + it('persists disabledSkills as string array', async () => { + await writeAppConfig(dataDir, { disabledSkills: ['skill-a', 'skill-b'] }); + const cfg = await readAppConfig(dataDir); + expect(cfg.disabledSkills).toEqual(['skill-a', 'skill-b']); + }); + + it('persists disabledDesignSystems as string array', async () => { + await writeAppConfig(dataDir, { disabledDesignSystems: ['ds-x'] }); + const cfg = await readAppConfig(dataDir); + expect(cfg.disabledDesignSystems).toEqual(['ds-x']); + }); + + it('drops disabledSkills when not a string array', async () => { + await writeAppConfig(dataDir, { disabledSkills: 'not-array' } as any); + const cfg = await readAppConfig(dataDir); + expect(cfg.disabledSkills).toBeUndefined(); + }); + + it('drops disabledSkills with non-string elements', async () => { + await writeAppConfig(dataDir, { disabledSkills: [1, 2, 3] } as any); + const cfg = await readAppConfig(dataDir); + expect(cfg.disabledSkills).toBeUndefined(); + }); + + it('clears disabledSkills when empty array is sent', async () => { + await writeAppConfig(dataDir, { disabledSkills: ['a'] }); + await writeAppConfig(dataDir, { disabledSkills: [] }); + const cfg = await readAppConfig(dataDir); + expect(cfg.disabledSkills).toEqual([]); + }); +}); + describe('app-config origin guard', () => { let server: http.Server; let port: number; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c0928729f..faa7aed1e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { EntryView } from './components/EntryView'; import type { CreateInput } from './components/NewProjectPanel'; import { PetOverlay } from './components/pet/PetOverlay'; @@ -182,6 +182,12 @@ export function App() { ...daemonConfig.agentModels, }; } + if (daemonConfig.disabledSkills !== undefined) { + next.disabledSkills = daemonConfig.disabledSkills; + } + if (daemonConfig.disabledDesignSystems !== undefined) { + next.disabledDesignSystems = daemonConfig.disabledDesignSystems; + } } if (alive) { @@ -520,6 +526,18 @@ export function App() { void refreshTemplates(); }, [route.kind, refreshTemplates]); + const enabledSkills = useMemo( + () => skills.filter((s) => !(config.disabledSkills ?? []).includes(s.id)), + [skills, config.disabledSkills], + ); + const enabledDS = useMemo( + () => + designSystems.filter( + (d) => !(config.disabledDesignSystems ?? []).includes(d.id), + ), + [designSystems, config.disabledDesignSystems], + ); + return ( <> {activeProject ? ( @@ -548,8 +566,8 @@ export function App() { /> ) : ( >; +} + +const MODES = [ + 'prototype', + 'deck', + 'template', + 'design-system', + 'image', + 'video', + 'audio', +] as const; + +export function LibrarySection({ cfg, setCfg }: Props) { + const t = useT(); + const [tab, setTab] = useState('skills'); + const [search, setSearch] = useState(''); + const [modeFilter, setModeFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('All'); + const [skills, setSkills] = useState([]); + const [designSystems, setDesignSystems] = useState([]); + const [previewId, setPreviewId] = useState(null); + const [previewBody, setPreviewBody] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + useEffect(() => { + fetchSkills().then(setSkills); + fetchDesignSystems().then(setDesignSystems); + }, []); + + const categories = useMemo(() => { + const cats = new Set(designSystems.map((d) => d.category)); + return ['All', ...Array.from(cats).sort()]; + }, [designSystems]); + + const disabledSkills = useMemo( + () => new Set(cfg.disabledSkills ?? []), + [cfg.disabledSkills], + ); + const disabledDS = useMemo( + () => new Set(cfg.disabledDesignSystems ?? []), + [cfg.disabledDesignSystems], + ); + + const filteredSkills = useMemo(() => { + const q = search.toLowerCase(); + return skills.filter((s) => { + if (modeFilter !== 'all' && s.mode !== modeFilter) return false; + if (q && !s.name.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q)) + return false; + return true; + }); + }, [skills, modeFilter, search]); + + const filteredDS = useMemo(() => { + const q = search.toLowerCase(); + return designSystems.filter((d) => { + if (categoryFilter !== 'All' && d.category !== categoryFilter) return false; + if (q && !d.title.toLowerCase().includes(q) && !d.summary.toLowerCase().includes(q)) + return false; + return true; + }); + }, [designSystems, categoryFilter, search]); + + const groupedSkills = useMemo(() => { + const groups = new Map(); + for (const s of filteredSkills) { + const list = groups.get(s.mode) ?? []; + list.push(s); + groups.set(s.mode, list); + } + return groups; + }, [filteredSkills]); + + const groupedDS = useMemo(() => { + const groups = new Map(); + for (const d of filteredDS) { + const list = groups.get(d.category) ?? []; + list.push(d); + groups.set(d.category, list); + } + return groups; + }, [filteredDS]); + + const openPreview = useCallback( + async (id: string) => { + if (previewId === id) { + setPreviewId(null); + setPreviewBody(null); + return; + } + setPreviewId(id); + setPreviewBody(null); + setPreviewLoading(true); + try { + const detail = + tab === 'skills' + ? await fetchSkill(id) + : await fetchDesignSystem(id); + setPreviewId((cur) => { + if (cur === id) setPreviewBody(detail?.body ?? null); + return cur; + }); + } catch { + setPreviewId((cur) => { + if (cur === id) setPreviewBody(null); + return cur; + }); + } finally { + setPreviewId((cur) => { + if (cur === id) setPreviewLoading(false); + return cur; + }); + } + }, + [previewId, tab], + ); + + function toggleSkillDisabled(id: string, disabled: boolean) { + setCfg((c) => { + const set = new Set(c.disabledSkills ?? []); + if (disabled) set.add(id); + else set.delete(id); + return { ...c, disabledSkills: [...set] }; + }); + } + + function toggleDSDisabled(id: string, disabled: boolean) { + setCfg((c) => { + const set = new Set(c.disabledDesignSystems ?? []); + if (disabled) set.add(id); + else set.delete(id); + return { ...c, disabledDesignSystems: [...set] }; + }); + } + + return ( +
+
+
+

{t('settings.library')}

+

{t('settings.libraryHint')}

+
+
+ +
+ + +
+ +
+ setSearch(e.target.value)} + /> + {tab === 'skills' ? ( +
+ + {MODES.map((mode) => { + const count = skills.filter((s) => s.mode === mode).length; + if (count === 0) return null; + return ( + + ); + })} +
+ ) : ( +
+ {categories.map((cat) => { + const count = + cat === 'All' + ? designSystems.length + : designSystems.filter((d) => d.category === cat).length; + return ( + + ); + })} +
+ )} +
+ +
+ {tab === 'skills' ? ( + filteredSkills.length === 0 ? ( +

{t('settings.libraryNoResults')}

+ ) : ( + MODES.filter((m) => groupedSkills.has(m)).map((mode) => ( +
+

+ {mode}{' '} + {groupedSkills.get(mode)!.length} +

+ {groupedSkills.get(mode)!.map((skill) => ( +
+
+
+ {skill.name} + {skill.previewType} +
+
{skill.description}
+
+ + + {previewId === skill.id && ( +
+ {previewLoading ? ( +

{t('settings.libraryLoading')}

+ ) : previewBody ? ( +
{previewBody}
+ ) : null} +
+ )} +
+ ))} +
+ )) + ) + ) : filteredDS.length === 0 ? ( +

{t('settings.libraryNoResults')}

+ ) : ( + <> + {Array.from(groupedDS.entries()).map(([category, items]) => ( +
+

+ {category} {items.length} +

+
+ {items.map((ds) => ( +
+
openPreview(ds.id)}> + {ds.swatches && ds.swatches.length > 0 && ( +
+ {ds.swatches.slice(0, 4).map((c, i) => ( + + ))} +
+ )} +
{ds.title}
+
{ds.summary}
+
+ +
+ ))} +
+
+ ))} + {previewId && filteredDS.some((d) => d.id === previewId) && ( +
+ {previewLoading ? ( +

{t('settings.libraryLoading')}

+ ) : previewBody ? ( +
{previewBody}
+ ) : null} +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index c064d52c6..916c022be 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import type { AgentInfo, ApiProtocol, ApiProtocolConfig, AppConfig, AppTheme, Ap import { MEDIA_PROVIDERS } from '../media/models'; import type { MediaProvider } from '../media/models'; import { PetSettings } from './pet/PetSettings'; +import { LibrarySection } from './LibrarySection'; import { DEFAULT_NOTIFICATIONS } from '../state/config'; import { FAILURE_SOUNDS, @@ -38,6 +39,7 @@ export type SettingsSection = | 'appearance' | 'notifications' | 'pet' + | 'library' | 'about'; interface Props { @@ -507,6 +509,17 @@ export function SettingsDialog({ {t('pet.navHint')} +