mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
3abcb3a4d2
commit
c554f14973
4 changed files with 67 additions and 6 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue