Files
fusion/packages/dashboard/app/hooks/useDiscoveredSkillsCache.ts
gsxdsm 9a9e591b72 FN-9115: clarify and expand agent skill selection
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>
2026-08-15 23:14:24 -07:00

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