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>
97 lines
6.1 KiB
TypeScript
97 lines
6.1 KiB
TypeScript
import "./SkillMultiselect.css";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { fetchDiscoveredSkills } from "../api";
|
||
import type { DiscoveredSkill } from "../api";
|
||
import { classifyAgentSkill, formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||
import { LoadingSpinner } from "./LoadingSpinner";
|
||
|
||
export interface SkillMultiselectProps {
|
||
value: string[];
|
||
onChange: (skills: string[]) => void;
|
||
projectId?: string;
|
||
disabled?: boolean;
|
||
id?: string;
|
||
label?: string;
|
||
ariaDescribedBy?: string;
|
||
}
|
||
|
||
/**
|
||
* FNXC:AgentSkillsUI 2026-08-16-04:27:
|
||
* Issue #1422 requires selecting several forced-reading skills in one pass. FN-9114 makes
|
||
* project-enabled skills automatic, so this list states both automatic availability and forced
|
||
* selection instead of implying that every stored skill is delivered.
|
||
*
|
||
* FNXC:AgentSettingsTheming 2026-08-16-04:27:
|
||
* Loading, error, empty, no-matches, populated, all-selected, and disabled classes are stable
|
||
* theming states for Agent Settings. Preserve them while keeping list selection deduplicated.
|
||
*/
|
||
export function SkillMultiselect({ value, onChange, projectId, disabled = false, id, label = "Skills", ariaDescribedBy }: SkillMultiselectProps) {
|
||
const { t } = useTranslation("app");
|
||
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [hasError, setHasError] = useState(false);
|
||
const [filter, setFilter] = useState("");
|
||
const [requestVersion, setRequestVersion] = useState(0);
|
||
const selectedIds = useMemo(() => [...new Set(value)], [value]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setIsLoading(true);
|
||
setHasError(false);
|
||
fetchDiscoveredSkills(projectId)
|
||
.then((discovered) => { if (!cancelled) setSkills(discovered); })
|
||
.catch(() => { if (!cancelled) setHasError(true); })
|
||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, [projectId, requestVersion]);
|
||
|
||
const skillById = useMemo(() => new Map(skills.map((skill) => [skill.id, skill])), [skills]);
|
||
const visibleSkills = useMemo(() => {
|
||
const needle = filter.trim().toLowerCase();
|
||
if (!needle) return skills;
|
||
return skills.filter((skill) => `${skill.name} ${skill.relativePath}`.toLowerCase().includes(needle));
|
||
}, [filter, skills]);
|
||
|
||
const updateSelection = (skillId: string, checked: boolean) => {
|
||
const next = checked ? [...selectedIds, skillId] : selectedIds.filter((entry) => entry !== skillId);
|
||
onChange([...new Set(next)]);
|
||
};
|
||
|
||
return (
|
||
<div className="skill-multiselect" data-testid="skill-multiselect">
|
||
{label && <span className="skill-multiselect-label">{label}</span>}
|
||
{selectedIds.length > 0 && (
|
||
<div className="skill-multiselect-chips" data-testid="skill-chips">
|
||
{selectedIds.map((skillId) => {
|
||
const classification = classifyAgentSkill(skillId, hasError ? null : skills, { forced: true });
|
||
return <span key={skillId} className="skill-chip" data-testid={`skill-chip-${skillId}`}>
|
||
<span className="skill-chip-name">{skillById.get(skillId)?.name ?? formatAgentSkillBadgeLabel(skillId)}</span>
|
||
<span className="skill-state-marker" data-skill-state={classification.state}>{t(classification.labelKey, classification.defaultLabel)}</span>
|
||
<button type="button" className="skill-chip-remove" onClick={() => updateSelection(skillId, false)} disabled={disabled} aria-label={t("skills.removeSkill", "Remove {{name}}", { name: formatAgentSkillBadgeLabel(skillId) })} data-testid={`remove-skill-${skillId}`}>×</button>
|
||
</span>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{isLoading ? <span className="skill-multiselect-loading" data-testid="skills-loading"><LoadingSpinner label={t("skills.loading", "Loading skills…")} /></span>
|
||
: hasError ? <div className="skill-multiselect-error" data-testid="skills-error" role="alert">{t("skills.loadError", "Skills could not be loaded.")} <button className="btn btn-sm" type="button" disabled={disabled} onClick={() => setRequestVersion((version) => version + 1)}>{t("common.retry", "Retry")}</button></div>
|
||
: skills.length === 0 ? <span className="skill-multiselect-empty" data-testid="skills-empty">{t("skills.noSkillsDiscovered", "No skills discovered")}</span>
|
||
: <>
|
||
<input id={id} className="input skill-multiselect-filter" type="search" value={filter} disabled={disabled} onChange={(event) => setFilter(event.target.value)} placeholder={t("skills.filter", "Filter skills")} aria-label={t("skills.filter", "Filter skills")} aria-describedby={ariaDescribedBy} data-testid="skill-filter" />
|
||
{visibleSkills.length === 0 ? <span className="skill-multiselect-no-matches" data-testid="skills-no-matches">{t("skills.noMatches", "No matching skills")}</span>
|
||
: <div className="skill-multiselect-list" role="list">{visibleSkills.map((skill) => {
|
||
const checked = selectedIds.includes(skill.id);
|
||
const classification = classifyAgentSkill(skill.id, skills, { forced: checked });
|
||
return <label className="skill-multiselect-option" key={skill.id} data-testid={`skill-option-${skill.id}`}>
|
||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => updateSelection(skill.id, event.target.checked)} />
|
||
<span className="skill-multiselect-option__content"><span>{skill.name}</span><span className="skill-multiselect-option__path">{skill.relativePath}</span></span>
|
||
<span className="skill-state-marker" data-skill-state={classification.state} title={t(classification.titleKey, classification.defaultTitle)}>{t(classification.labelKey, classification.defaultLabel)}</span>
|
||
{checked && <span className="skill-state-marker skill-state-marker--forced">{t("skills.forced", "Forced")}</span>}
|
||
</label>;
|
||
})}</div>}
|
||
{selectedIds.length === skills.length && <span className="skill-multiselect-all-selected">{t("skills.allSkillsSelected", "All skills selected")}</span>}
|
||
</>}
|
||
</div>
|
||
);
|
||
}
|