import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react"; import { fetchDiscoveredSkills, toggleExecutionSkill, fetchSkillsCatalog, fetchSkillContent, } from "../api"; import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashboard"; import type { ToastType } from "../hooks/useToast"; interface SkillsViewProps { projectId?: string; addToast: (message: string, type?: ToastType) => void; onClose: () => void; } export interface DiscoveredSkillDisplay extends DiscoveredSkill { toggling?: boolean; } export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { const [discoveredSkills, setDiscoveredSkills] = useState([]); const [isLoadingDiscovered, setIsLoadingDiscovered] = useState(true); const [isLoadingCatalog, setIsLoadingCatalog] = useState(false); const [catalogError, setCatalogError] = useState(null); const [catalogEntries, setCatalogEntries] = useState([]); const [searchQuery, setSearchQuery] = useState(""); // Skill content viewing state const [selectedSkillId, setSelectedSkillId] = useState(null); const [skillContent, setSkillContent] = useState(null); const [isLoadingContent, setIsLoadingContent] = useState(false); const [contentError, setContentError] = useState(null); // Debounce timer for catalog search const debounceRef = useRef | null>(null); const [debouncedQuery, setDebouncedQuery] = useState(""); // Client-side filtering for discovered skills const filteredDiscoveredSkills = searchQuery.trim() ? discoveredSkills.filter( (s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()) || s.relativePath.toLowerCase().includes(searchQuery.toLowerCase()) ) : discoveredSkills; // Fetch discovered skills const loadDiscoveredSkills = useCallback(async () => { setIsLoadingDiscovered(true); try { const skills = await fetchDiscoveredSkills(projectId); setDiscoveredSkills(skills); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load discovered skills"; addToast(message, "error"); } finally { setIsLoadingDiscovered(false); } }, [projectId, addToast]); // Fetch catalog const loadCatalog = useCallback(async (query: string) => { setIsLoadingCatalog(true); setCatalogError(null); try { const result = await fetchSkillsCatalog(query, 20, projectId); setCatalogEntries(result.entries); } catch (err) { // Check for upstream error with code (502 etc.) const error = err as { error?: string; code?: string }; if (error.error && error.code) { setCatalogError("Catalog is temporarily unavailable. Please try again later."); } else { const message = err instanceof Error ? err.message : "Failed to load catalog"; setCatalogError(message); } } finally { setIsLoadingCatalog(false); } }, [projectId]); // Initial load useEffect(() => { void loadDiscoveredSkills(); void loadCatalog(""); }, [loadDiscoveredSkills, loadCatalog]); // Handle search input with debounce const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { setDebouncedQuery(value); debounceRef.current = null; }, 300); }, []); useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; } }; }, []); // Fetch catalog when debounced query changes useEffect(() => { void loadCatalog(debouncedQuery); }, [debouncedQuery, loadCatalog]); // Handle toggle skill const handleToggleSkill = useCallback(async (skillId: string, currentEnabled: boolean) => { const newEnabled = !currentEnabled; // Optimistic update setDiscoveredSkills((prev) => prev.map((s) => (s.id === skillId ? { ...s, toggling: true } : s)) ); try { await toggleExecutionSkill(skillId, newEnabled, projectId); // Update local state with new enabled value setDiscoveredSkills((prev) => prev.map((s) => (s.id === skillId ? { ...s, enabled: newEnabled, toggling: false } : s)) ); addToast(`Skill ${newEnabled ? "enabled" : "disabled"}`, "success"); } catch (err) { // Revert optimistic update setDiscoveredSkills((prev) => prev.map((s) => (s.id === skillId ? { ...s, toggling: false } : s)) ); const message = err instanceof Error ? err.message : "Failed to toggle skill"; addToast(`Failed to toggle skill: ${message}`, "error"); } }, [projectId, addToast]); const loadSkillContent = useCallback(async (skillId: string) => { setIsLoadingContent(true); setContentError(null); setSkillContent(null); try { const content = await fetchSkillContent(skillId, projectId); setSkillContent(content); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load skill content"; setContentError(message); } finally { setIsLoadingContent(false); } }, [projectId]); // Handle click on discovered skill to view content const handleSkillClick = useCallback((skillId: string, event?: MouseEvent) => { if (event) { const target = event.target as Element; if (target.closest(".skills-view-item-toggle")) { return; } } if (selectedSkillId === skillId) { setSelectedSkillId(null); setSkillContent(null); setContentError(null); return; } setSelectedSkillId(skillId); void loadSkillContent(skillId); }, [selectedSkillId, loadSkillContent]); const handleRetrySkillContent = useCallback((skillId: string) => { if (selectedSkillId !== skillId) { setSelectedSkillId(skillId); } void loadSkillContent(skillId); }, [loadSkillContent, selectedSkillId]); return (
{/* Header */}

Skills

{discoveredSkills.length} discovered
{/* Scrollable content area */}
{/* Search — at top for both sections */}
handleSearchChange(e.target.value)} aria-label="Search skills" />
{/* Discovered Skills Section */}

Discovered Skills

{isLoadingDiscovered ? (
Loading discovered skills...
) : discoveredSkills.length === 0 ? (

No skills discovered in this project.

) : filteredDiscoveredSkills.length === 0 ? (

No discovered skills match your search.

) : (
{filteredDiscoveredSkills.map((skill) => { const isSelected = selectedSkillId === skill.id; return (
handleSkillClick(skill.id, event)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleSkillClick(skill.id); } }} aria-expanded={isSelected} aria-label={`View details for ${skill.name}`} >
{isSelected ? : } {skill.name} {skill.relativePath} {skill.metadata.source}
{/* Skill Content Detail Panel */} {isSelected && (
{skill.name}
{isLoadingContent ? (
Loading skill content...
) : contentError ? (
{contentError}
) : skillContent ? ( <>
                              {skillContent.skillMd || "(No SKILL.md found)"}
                            
{skillContent.files.length > 0 && (
Files: {skillContent.files.map((file) => ( {file.name} {file.type === "directory" && "/"} ))}
)} ) : null}
)}
); })}
)}
{/* Catalog Section */}

Skills Catalog

{/* Catalog Content */} {catalogError ? (

{catalogError}

) : isLoadingCatalog ? (
Loading catalog...
) : catalogEntries.length === 0 ? (
{searchQuery ? (

No skills match your search.

) : (

No skills available in the catalog.

)}
) : (
{catalogEntries.map((entry) => (

{entry.name}

{entry.description && (

{entry.description}

)} {entry.tags && entry.tags.length > 0 && (
{entry.tags.map((tag) => ( {tag} ))}
)} {entry.installs !== undefined && ( {entry.installs.toLocaleString()} installs )}
))}
)}
); }