'use client'; /** * Skill management section of the global settings dialog. * * Lists the skills installed for the current account — built-in skills that * ship with the product and the owner's own skills created from chat history — * from the owner-scoped `GET /api/agent/skills` registry. The row layout and * the grouped list follow the reference skill-settings dialog, which this * surface replaces with REAL endpoints only: * * - every row opens a detail view (`SkillDetailDialog`) and offers a real * Download action that hits `GET /api/skills/:id` and ships the zip the * server builds; * - a user skill's detail view loads its full body from the owner-scoped * detail route (`GET /api/agent/skills/:id`); built-in skills have no * detail route, so their detail view shows what the registry already * carries and never issues a request that would 404. * * Owner rows can also be deleted after confirmation, and exported zips or bare * SKILL.md files can be uploaded through the owner-scoped registry endpoint. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Download, Loader2, Sparkles, Trash2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { useI18n } from '@/lib/hooks/use-i18n'; import { agentSkillsErrorText, skillTitle, useAgentSkills, type AgentSkillInfo, } from '@/lib/workbench/agent-skills'; import { cn } from '@/lib/utils'; /** * The real Download affordance for one skill. A plain anchor to the export * route (`GET /api/skills/:id`): same-origin, so the `download` attribute * names the file and the server's `Content-Disposition` keeps it a download * in every browser either way. */ function DownloadLink({ skill }: { skill: AgentSkillInfo }) { const { t } = useI18n(); return ( {t('settings.skills.download')} ); } /** The kind/constraint pills a row and the detail view share. */ function SkillBadges({ skill }: { skill: AgentSkillInfo }) { const { t } = useI18n(); return ( <> {skill.source === 'user' ? t('settings.skills.badgeOwner') : t('settings.skills.badgeBuiltin')} {skill.hasConstraints && ( {t('settings.skills.badgeConstraints')} )} ); } function SkillRow({ skill, onDetails, onDelete, }: { skill: AgentSkillInfo; onDetails: (skill: AgentSkillInfo) => void; onDelete?: (skill: AgentSkillInfo) => void; }) { const { t } = useI18n(); const title = skillTitle(skill, t); return (
{title ? ( {title} ) : null} {/* The English id is the skill's contract — it is never dropped. */} /{skill.name}

{skill.description}

{onDelete ? ( ) : null}
); } function SkillGroup({ label, skills, emptyLabel, testId, onDetails, onDelete, }: { label: string; skills: AgentSkillInfo[]; emptyLabel: string; testId: string; onDetails: (skill: AgentSkillInfo) => void; onDelete?: (skill: AgentSkillInfo) => void; }) { return (
{/* The label sits OUTSIDE the bordered box — it is the group's heading, not a row of the list. */}

{label}

{skills.length === 0 ? (

{emptyLabel}

) : ( skills.map((skill) => ( )) )}
); } interface SkillContentState { loading: boolean; failed: boolean; content: string | null; } /** * The full body of ONE user skill, from the owner-scoped detail route * (`GET /api/agent/skills/:id`). Built-in skills have no detail route — their * registry row already carries the whole story — so this hook is only ever * handed a user-skill id and never issues a request that would 404. */ function useUserSkillContent(id: string | null): SkillContentState & { retry: () => void } { const [state, setState] = useState({ loading: false, failed: false, content: null, }); const [attempt, setAttempt] = useState(0); useEffect(() => { if (!id) return; let cancelled = false; // The dialog re-opens per skill: reset synchronously so the previous // skill's body never flashes under the new one's loading state. // eslint-disable-next-line react-hooks/set-state-in-effect setState({ loading: true, failed: false, content: null }); fetch(`/api/agent/skills/${encodeURIComponent(id)}`) .then(async (res) => { if (!res.ok) throw new Error(`skill detail request failed: ${res.status}`); const body = (await res.json()) as { id: string; content: string }; if (!cancelled) setState({ loading: false, failed: false, content: body.content }); }) .catch(() => { if (!cancelled) setState({ loading: false, failed: true, content: null }); }); return () => { cancelled = true; }; }, [id, attempt]); const retry = useCallback(() => setAttempt((n) => n + 1), []); return { ...state, retry }; } /** * The detail view, laid out like the reference skill-settings dialog: a * header carrying the display name + id and the one-line description, the * kind/constraint pills, and the skill body (user skills) or a note that the * built-in ships with the product. Download stays available in the footer. */ function SkillDetailDialog({ skill, onClose, }: { skill: AgentSkillInfo | null; onClose: () => void; }) { const { t } = useI18n(); const content = useUserSkillContent(skill && skill.source === 'user' ? skill.id : null); return ( !open && onClose()}> {skill ? ( <> {skillTitle(skill, t) ?? skill.name} /{skill.name} {skill.description}
{skill.source === 'user' ? ( content.loading ? (

{t('common.loading')}

) : content.failed ? (

{t('settings.skills.detailFailed')}

) : (

{t('settings.skills.contentLabel')}

                    {content.content}
                  
) ) : (

{t('settings.skills.builtinDetailNote')}

)} ) : null}
); } /** * The "Skills" section body, mounted by the settings dialog when its sidebar * selects the section. Grouped by kind — the owner's skills first, then the * built-ins — with the reference's loading / failed / empty patterns. */ export function SkillSettings() { const { t } = useI18n(); const { skills, loading, error, reload } = useAgentSkills(); const [detailSkill, setDetailSkill] = useState(null); const [deleteSkill, setDeleteSkill] = useState(null); const [deleting, setDeleting] = useState(false); const [uploading, setUploading] = useState(false); const [actionError, setActionError] = useState<'deleteFailed' | 'uploadFailed' | null>(null); const [hiddenSkillIds, setHiddenSkillIds] = useState>(() => new Set()); const [uploadedSkills, setUploadedSkills] = useState([]); const uploadRef = useRef(null); const visibleSkills = [ ...skills, ...uploadedSkills.filter((uploaded) => !skills.some((skill) => skill.id === uploaded.id)), ].filter((skill) => !hiddenSkillIds.has(skill.id)); const userSkills = visibleSkills.filter((skill) => skill.source === 'user'); const builtinSkills = visibleSkills.filter((skill) => skill.source === 'builtin'); const openDetails = useCallback((skill: AgentSkillInfo) => setDetailSkill(skill), []); const confirmDelete = useCallback(async () => { if (!deleteSkill || deleting) return; setDeleting(true); setActionError(null); try { const response = await fetch(`/api/agent/skills/${encodeURIComponent(deleteSkill.id)}`, { method: 'DELETE', }); if (!response.ok) throw new Error(`skill delete request failed: ${response.status}`); setHiddenSkillIds((current) => new Set(current).add(deleteSkill.id)); setDeleteSkill(null); if (detailSkill?.id === deleteSkill.id) setDetailSkill(null); await reload().catch(() => {}); } catch { setActionError('deleteFailed'); } finally { setDeleting(false); } }, [deleteSkill, deleting, detailSkill?.id, reload]); const uploadSkill = useCallback( async (file: File) => { setUploading(true); setActionError(null); try { const form = new FormData(); form.set('file', file); const response = await fetch('/api/agent/skills', { method: 'POST', body: form }); if (!response.ok) throw new Error(`skill upload request failed: ${response.status}`); const uploaded = (await response.json()) as AgentSkillInfo; setUploadedSkills((current) => [ ...current.filter((skill) => skill.id !== uploaded.id), uploaded, ]); await reload().catch(() => {}); } catch { setActionError('uploadFailed'); } finally { setUploading(false); if (uploadRef.current) uploadRef.current.value = ''; } }, [reload], ); return (

{t('settings.skills.description')}

{ const file = event.currentTarget.files?.[0]; if (file) void uploadSkill(file); }} />
{actionError ? (

{t(`settings.skills.${actionError}`)}

) : null} {loading ? (

{t('common.loading')}

) : error ? ( // A failed list answers BOTH groups at once — rendering empty boxes // under an error would read as "you have no skills".

{agentSkillsErrorText({ error }, t)}

) : ( <> )} setDetailSkill(null)} /> !open && setDeleteSkill(null)} > {t('settings.skills.deleteTitle')} {t('settings.skills.deleteConfirm')} {t('common.cancel')} { event.preventDefault(); void confirmDelete(); }} > {deleting ? t('settings.skills.deleting') : t('settings.skills.delete')}
); }