Clarify automatic skill availability while making forced-reading selections easier to manage. - Replace the skill picker with a searchable multi-select checkbox list and explicit loading, error, empty, and unavailable states. - Label forced, disabled, and undiscovered skills consistently across agent detail and list views, including a clear None state. - Update localized guidance, documentation, regression coverage, and the published package changeset. Files changed: .changeset/fn-9115-skills-ui.md | 7 + docs/dashboard-guide.md | 4 + .../agent-detail-settings-theme-styling.test.ts | 3 +- .../dashboard/app/components/AgentDetailView.css | 7 +- .../dashboard/app/components/AgentDetailView.tsx | 35 ++- packages/dashboard/app/components/AgentsView.css | 4 + packages/dashboard/app/components/AgentsView.tsx | 13 +- .../dashboard/app/components/NewAgentDialog.tsx | 5 +- .../dashboard/app/components/SkillMultiselect.css | 258 +++------------------ .../dashboard/app/components/SkillMultiselect.tsx | 178 +++++--------- .../AgentDetailView.mobile-scroll.test.tsx | 4 +- .../AgentDetailView.skills-procedure.test.tsx | 59 +++-- .../app/components/__tests__/AgentsView.test.tsx | 19 +- .../components/__tests__/SkillMultiselect.test.tsx | 81 ++++--- .../__tests__/useDiscoveredSkillsCache.test.ts | 16 ++ .../app/hooks/useDiscoveredSkillsCache.ts | 13 +- .../app/utils/__tests__/agentSkills.test.ts | 28 +++ packages/dashboard/app/utils/agentSkills.ts | 69 +++++- packages/i18n/locales/en/app.json | 21 +- packages/i18n/locales/es/app.json | 21 +- packages/i18n/locales/fr/app.json | 21 +- packages/i18n/locales/ko/app.json | 21 +- packages/i18n/locales/pt-BR/app.json | 21 +- packages/i18n/locales/zh-CN/app.json | 21 +- packages/i18n/locales/zh-TW/app.json | 21 +- 25 files changed, 493 insertions(+), 457 deletions(-) Fusion-Task-Id: FN-9115 Fusion-Task-Lineage: 18229b87-d0dc-41e7-9333-2d12df852486 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchDiscoveredSkills, type DiscoveredSkill } from "../api";
|
|
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
|
|
|
export interface UseDiscoveredSkillsCacheResult {
|
|
skills: DiscoveredSkill[];
|
|
loading: boolean;
|
|
/** True when discovery could not refresh; consumers must not infer undiscovered IDs. */
|
|
error: boolean;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
const inflightByProject = new Map<string, Promise<DiscoveredSkill[]>>();
|
|
const listenersByProject = new Map<string, Set<(skills: DiscoveredSkill[]) => void>>();
|
|
|
|
function getProjectKey(projectId?: string): string {
|
|
return projectId ?? "global";
|
|
}
|
|
|
|
function getCacheKey(projectId?: string): string {
|
|
return `${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}${getProjectKey(projectId)}`;
|
|
}
|
|
|
|
function readCachedSkills(projectId?: string): DiscoveredSkill[] | null {
|
|
return readCache<DiscoveredSkill[]>(getCacheKey(projectId), { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
|
}
|
|
|
|
function notifyListeners(projectKey: string, skills: DiscoveredSkill[]): void {
|
|
for (const listener of listenersByProject.get(projectKey) ?? []) {
|
|
listener(skills);
|
|
}
|
|
}
|
|
|
|
async function fetchSharedSkills(projectId?: string): Promise<DiscoveredSkill[]> {
|
|
const projectKey = getProjectKey(projectId);
|
|
const existing = inflightByProject.get(projectKey);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const request = fetchDiscoveredSkills(projectId).finally(() => {
|
|
inflightByProject.delete(projectKey);
|
|
});
|
|
inflightByProject.set(projectKey, request);
|
|
return request;
|
|
}
|
|
|
|
export function useDiscoveredSkillsCache(projectId?: string): UseDiscoveredSkillsCacheResult {
|
|
const [skills, setSkills] = useState<DiscoveredSkill[]>(() => readCachedSkills(projectId) ?? []);
|
|
const [loading, setLoading] = useState(() => readCachedSkills(projectId) === null);
|
|
const [error, setError] = useState(false);
|
|
const hasCachedStateRef = useRef(readCachedSkills(projectId) !== null);
|
|
const projectKey = getProjectKey(projectId);
|
|
|
|
useEffect(() => {
|
|
const cachedSkills = readCachedSkills(projectId) ?? [];
|
|
setSkills(cachedSkills);
|
|
setLoading(readCachedSkills(projectId) === null);
|
|
setError(false);
|
|
hasCachedStateRef.current = readCachedSkills(projectId) !== null;
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
const listeners = listenersByProject.get(projectKey) ?? new Set<(skills: DiscoveredSkill[]) => void>();
|
|
listeners.add(setSkills);
|
|
listenersByProject.set(projectKey, listeners);
|
|
return () => {
|
|
listeners.delete(setSkills);
|
|
if (listeners.size === 0) {
|
|
listenersByProject.delete(projectKey);
|
|
}
|
|
};
|
|
}, [projectKey]);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const nextSkills = await fetchSharedSkills(projectId);
|
|
hasCachedStateRef.current = true;
|
|
setError(false);
|
|
writeCache(getCacheKey(projectId), nextSkills, { maxBytes: 500_000 });
|
|
notifyListeners(projectKey, nextSkills);
|
|
} catch {
|
|
/*
|
|
* FNXC:AgentSkillsUI 2026-08-16-04:42:
|
|
* A failed discovery request cannot prove a stored skill disappeared. Expose the failure so
|
|
* badge renderers retain their neutral pending classification until discovery succeeds.
|
|
*/
|
|
setError(true);
|
|
if (!hasCachedStateRef.current) {
|
|
clearCache(getCacheKey(projectId));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId, projectKey]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true);
|
|
await load();
|
|
}, [load]);
|
|
|
|
return { skills, loading, error, refresh };
|
|
}
|