diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3c648a87a..42b1eda7d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -607,6 +607,11 @@ export function App() { setDesignSystems(list); }, []); + const refreshSkills = useCallback(async () => { + const list = await fetchSkills(); + setSkills(list); + }, []); + const refreshTemplates = useCallback(async () => { const list = await listTemplates(); setTemplates(list); @@ -1513,6 +1518,7 @@ export function App() { setSettingsOpen(false); }} onRefreshAgents={refreshAgents} + onSkillsRefresh={refreshSkills} daemonMediaProviders={daemonMediaProviders} daemonMediaProvidersFetchState={daemonMediaProvidersFetchState} mediaProvidersNotice={mediaProvidersNotice} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index ec1a2a3e5..84fb06f6d 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -176,6 +176,8 @@ interface Props { onRefreshAgents: ( options?: AgentRefreshOptions, ) => AgentInfo[] | Promise | void; + /** Re-fetch functional skills into App state after Settings mutations. */ + onSkillsRefresh?: () => Promise | void; daemonMediaProviders?: AppConfig['mediaProviders'] | null; daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error'; mediaProvidersNotice?: string | null; @@ -797,6 +799,7 @@ export function SettingsDialog({ composioConfigLoading = false, onClose, onRefreshAgents, + onSkillsRefresh, daemonMediaProviders, daemonMediaProvidersFetchState = 'idle', mediaProvidersNotice, @@ -3355,7 +3358,11 @@ export function SettingsDialog({ ) : null} {activeSection === 'skills' ? ( - + ) : null} {activeSection === 'designSystems' ? ( diff --git a/apps/web/src/components/SkillsSection.tsx b/apps/web/src/components/SkillsSection.tsx index 0c1fb73c5..42eb91b61 100644 --- a/apps/web/src/components/SkillsSection.tsx +++ b/apps/web/src/components/SkillsSection.tsx @@ -34,6 +34,7 @@ import { interface Props { cfg: AppConfig; setCfg: Dispatch>; + onSkillsRefresh?: () => Promise | void; } type SourceFilter = 'all' | 'user' | 'built-in'; @@ -68,7 +69,7 @@ function parseTriggers(raw: string): string[] { .filter(Boolean); } -export function SkillsSection({ cfg, setCfg }: Props) { +export function SkillsSection({ cfg, setCfg, onSkillsRefresh }: Props) { const t = useT(); const [skills, setSkills] = useState([]); @@ -292,6 +293,7 @@ export function SkillsSection({ cfg, setCfg }: Props) { } const updated = result.skill; await refresh(); + await onSkillsRefresh?.(); setBodyById((cur) => ({ ...cur, [updated.id]: body })); // Drop the cached file tree for this id so the next expand // re-walks the on-disk folder; SKILL.md may have been the only @@ -305,7 +307,7 @@ export function SkillsSection({ cfg, setCfg }: Props) { setEditingId(null); setCreating(false); setDraft(EMPTY_DRAFT); - }, [draft, draftSaving, editingId, refresh]); + }, [draft, draftSaving, editingId, onSkillsRefresh, refresh]); const armDelete = useCallback((id: string) => { setConfirmDeleteId(id); @@ -324,6 +326,7 @@ export function SkillsSection({ cfg, setCfg }: Props) { } setConfirmDeleteId(null); await refresh(); + await onSkillsRefresh?.(); setBodyById((cur) => { const next = { ...cur }; delete next[id]; @@ -347,7 +350,7 @@ export function SkillsSection({ cfg, setCfg }: Props) { setDraft(EMPTY_DRAFT); } }, - [editingId, expandedId, refresh, setCfg], + [editingId, expandedId, onSkillsRefresh, refresh, setCfg], ); const toggleEnabled = useCallback( diff --git a/apps/web/tests/components/SkillsSection.test.tsx b/apps/web/tests/components/SkillsSection.test.tsx index 553f510a6..f79dfe500 100644 --- a/apps/web/tests/components/SkillsSection.test.tsx +++ b/apps/web/tests/components/SkillsSection.test.tsx @@ -39,8 +39,12 @@ function makeSkill(overrides: Partial): SkillSummary { }; } -function renderSkillsSection(skills: SkillSummary[]) { +function renderSkillsSection( + skills: SkillSummary[], + options?: { onSkillsRefresh?: () => void | Promise }, +) { const setCfg = vi.fn(); + const onSkillsRefresh = options?.onSkillsRefresh; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (url === '/api/skills' && (!init || init.method === undefined)) { @@ -49,6 +53,21 @@ function renderSkillsSection(skills: SkillSummary[]) { headers: { 'content-type': 'application/json' }, }); } + if (url === '/api/skills/import' && init?.method === 'POST') { + return new Response( + JSON.stringify({ + skill: makeSkill({ + id: 'new-skill', + name: 'New skill', + source: 'user', + }), + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + } if (url.startsWith('/api/skills/') && init?.method === 'DELETE') { return new Response(JSON.stringify({ ok: true }), { status: 200, @@ -62,9 +81,10 @@ function renderSkillsSection(skills: SkillSummary[]) { , ); - return { fetchMock: globalThis.fetch as ReturnType, setCfg }; + return { fetchMock: globalThis.fetch as ReturnType, setCfg, onSkillsRefresh }; } describe('SkillsSection', () => { @@ -155,4 +175,29 @@ describe('SkillsSection', () => { expect(within(row).queryByTestId('skills-edit-builtin-warning')).toBeNull(); expect(await within(row).findByTestId('skills-edit-form')).toBeTruthy(); }); + + it('refreshes app-level skills after creating a skill', async () => { + const onSkillsRefresh = vi.fn(); + renderSkillsSection([], { onSkillsRefresh }); + + fireEvent.click(await screen.findByTestId('skills-new')); + const form = await screen.findByTestId('skills-create-form'); + fireEvent.change(within(form).getByPlaceholderText('my-skill'), { + target: { value: 'New skill' }, + }); + fireEvent.change(within(form).getAllByRole('textbox').at(-1)!, { + target: { value: '# New skill\n\nDo the thing.' }, + }); + fireEvent.click(within(form).getByTestId('skills-save')); + + await waitFor(() => { + expect(onSkillsRefresh).toHaveBeenCalledTimes(1); + }); + expect( + (globalThis.fetch as ReturnType).mock.calls.some( + ([url, init]) => + url.toString() === '/api/skills/import' && init?.method === 'POST', + ), + ).toBe(true); + }); });