fix(web): refresh chat skills after Settings skill mutations (#3020)

SkillsSection kept its own skills list in sync after create/delete, but
App-level skills (used by the chat composer) were only loaded at boot.
Propagate a refresh callback so new skills appear in chat immediately.

Fixes #3017
This commit is contained in:
吴杨帆 2026-05-27 22:44:28 +08:00 committed by GitHub
parent 3abcb3a4d2
commit c554f14973
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 67 additions and 6 deletions

View file

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

View file

@ -176,6 +176,8 @@ interface Props {
onRefreshAgents: (
options?: AgentRefreshOptions,
) => AgentInfo[] | Promise<AgentInfo[] | void> | void;
/** Re-fetch functional skills into App state after Settings mutations. */
onSkillsRefresh?: () => Promise<void> | 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' ? (
<SkillsSection cfg={cfg} setCfg={setCfg} />
<SkillsSection
cfg={cfg}
setCfg={setCfg}
onSkillsRefresh={onSkillsRefresh}
/>
) : null}
{activeSection === 'designSystems' ? (

View file

@ -34,6 +34,7 @@ import {
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
onSkillsRefresh?: () => Promise<void> | 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<SkillSummary[]>([]);
@ -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(

View file

@ -39,8 +39,12 @@ function makeSkill(overrides: Partial<SkillSummary>): SkillSummary {
};
}
function renderSkillsSection(skills: SkillSummary[]) {
function renderSkillsSection(
skills: SkillSummary[],
options?: { onSkillsRefresh?: () => void | Promise<void> },
) {
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[]) {
<SkillsSection
cfg={TEST_CONFIG}
setCfg={setCfg}
onSkillsRefresh={onSkillsRefresh}
/>,
);
return { fetchMock: globalThis.fetch as ReturnType<typeof vi.fn>, setCfg };
return { fetchMock: globalThis.fetch as ReturnType<typeof vi.fn>, 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<typeof vi.fn>).mock.calls.some(
([url, init]) =>
url.toString() === '/api/skills/import' && init?.method === 'POST',
),
).toBe(true);
});
});