Files
fusion/packages/dashboard/app/components/SkillMultiselect.tsx
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

97 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}