Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import type { SkillInfo } from '@cr/shared';5import { Blocks, Search, Unplug } from 'lucide-react';6import { api, errorMessage, qs } from '@/lib/api';7import { qk, useSkills } from '@/lib/queries';8import { useCurrentProject } from '@/hooks/use-current';9import { toast } from '@/stores/toasts';10import { Badge } from '@/components/ui/badge';11import { Button } from '@/components/ui/button';12import { Input } from '@/components/ui/input';13import { Switch } from '@/components/ui/switch';14import { Sheet } from '@/components/ui/sheet';15import { ConfirmDialog } from '@/components/ui/dialog';16import { EmptyState, ErrorState, PageHeader, Section, Skeleton, Spinner } from '@/components/ui/misc';17import { useBrand } from '@/stores/branding';18import { configDirLabel, skillKindsPhrase, skillsEmptyHint } from '@/lib/branding';1920/** Result row of GET /skills/registry/search (server adapter; not part of @cr/shared). */21interface RegistryResult { name: string; type: string | null; summary: string; latestVersion: string | null; license: string | null; url: string | null; publisher: string | null; verified: boolean | null }22const KIND_LABEL = { skill: 'Skill', command: 'Command', agent: 'Agent' } as const;23const keyOf = (s: SkillInfo) => `${s.kind}:${s.source}:${s.name}`;2425export default function SkillsPage() {26 const brand = useBrand();27 const project = useCurrentProject();28 const q = useSkills(project?.id);29 // Only the kinds the instance's agent has (e.g. no slash commands for Codex).30 const kinds = brand.caps.skillKinds;31 const items = q.data?.filter((s) => kinds.includes(s.kind));32 const qc = useQueryClient();33 const [detail, setDetail] = useState<SkillInfo | null>(null);34 const [removing, setRemoving] = useState<SkillInfo | null>(null);35 const [busy, setBusy] = useState<string | null>(null);36 const op = async (s: SkillInfo, o: 'enable' | 'disable' | 'remove') => {37 setBusy(keyOf(s));38 try {39 await api.post('/skills/op', { op: o, name: s.name, kind: s.kind, scope: s.source === 'project' ? 'project' : 'user', ...(s.source === 'project' && project ? { projectId: project.id } : {}) });40 await qc.invalidateQueries({ queryKey: qk.skills });41 if (o === 'remove') setDetail(null);42 } catch (e) {43 toast.error('Skill operation failed', errorMessage(e));44 } finally {45 setBusy(null);46 }47 };48 return (49 <div className="flex h-full flex-col overflow-hidden">50 <PageHeader title="Skills" description={`${skillKindsPhrase(kinds)} installed for ${brand.agentName} on the server. Skill code is never executed by this page.`} />51 <div className="min-h-0 flex-1 overflow-y-auto">52 <div className="flex flex-col gap-4 p-4 md:p-6">53 <RegistrySearch />54 <Section title="Installed" aside={items ? `${items.length} item${items.length === 1 ? '' : 's'}` : undefined}>55 {q.isPending ? <div className="flex flex-col gap-2 p-4">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-10" />)}</div>56 : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />57 : !items || items.length === 0 ? <EmptyState icon={<Blocks />} title="No skills installed" description={skillsEmptyHint(brand)} />58 : (59 <ul className="divide-y divide-line" data-testid="skills-list">60 {items.map((s) => (61 <li key={keyOf(s)} className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-4 py-2.5" data-testid="skill-row">62 <button onClick={() => setDetail(s)} className="min-w-0 flex-1 basis-56 text-left">63 <span className="flex items-center gap-2">64 <span className={s.enabled ? 'truncate text-[13px] font-medium text-text' : 'truncate text-[13px] font-medium text-muted'}>{s.name}</span>65 <Badge tone="muted">{KIND_LABEL[s.kind]}</Badge>66 {s.updateAvailable && <Badge tone="accent">update available</Badge>}67 </span>68 <span className="mt-0.5 block truncate text-xs text-text-2">{s.description || 'No description'}</span>69 </button>70 <span className="hidden w-16 text-xs text-muted sm:block">{s.source}</span>71 <span className="tnum hidden w-14 text-xs text-muted sm:block">{s.version ?? '—'}</span>72 {busy === keyOf(s) ? <Spinner className="size-3.5" /> : <Switch label={`${s.enabled ? 'Disable' : 'Enable'} ${s.name}`} checked={s.enabled} onCheckedChange={(v) => op(s, v ? 'enable' : 'disable')} />}73 </li>74 ))}75 </ul>76 )}77 </Section>78 </div>79 </div>80 <Sheet open={!!detail} onOpenChange={(o) => !o && setDetail(null)} title={detail?.name ?? 'Skill'}>81 {detail && (82 <div className="flex flex-col gap-4 p-4 text-xs">83 <p className="text-[13px] leading-relaxed text-text-2">{detail.description || 'No description'}</p>84 <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5">85 <dt className="text-muted">Kind</dt><dd>{KIND_LABEL[detail.kind]}</dd>86 <dt className="text-muted">Source</dt><dd>{detail.source}</dd>87 <dt className="text-muted">Version</dt><dd>{detail.version ?? 'not declared'}</dd>88 <dt className="text-muted">Location</dt><dd className="break-all font-mono">{detail.location}</dd>89 <dt className="text-muted">State</dt><dd>{detail.enabled ? 'Enabled' : `Disabled (moved to ${configDirLabel(brand)}/.disabled/)`}</dd>90 <dt className="text-muted">Updates</dt><dd>{detail.updateAvailable == null ? 'Unknown — registry not connected' : detail.updateAvailable ? 'Update available' : 'Up to date'}</dd>91 </dl>92 <Button variant="danger" className="self-start" onClick={() => setRemoving(detail)}>Remove…</Button>93 </div>94 )}95 </Sheet>96 <ConfirmDialog97 open={!!removing}98 onOpenChange={(o) => !o && setRemoving(null)}99 title="Remove skill?"100 description={<>“{removing?.name}” will be deleted from <span className="font-mono">{removing?.location}</span> on the server.</>}101 confirmLabel="Remove"102 danger103 onConfirm={() => (removing ? op(removing, 'remove') : undefined)}104 />105 </div>106 );107}108109function RegistrySearch() {110 const [query, setQuery] = useState('');111 const [debounced, setDebounced] = useState('');112 useEffect(() => {113 const t = setTimeout(() => setDebounced(query.trim()), 300);114 return () => clearTimeout(t);115 }, [query]);116 const q = useQuery({117 queryKey: ['skills-registry', debounced],118 // the registry adapter requires a non-empty query; a one-character probe tells us whether it is connected119 queryFn: () => api.get<{ connected: boolean; results?: RegistryResult[] }>(`/skills/registry/search${qs({ q: debounced || 'a', limit: debounced ? 20 : 1 })}`),120 staleTime: 60_000,121 });122 const connected = q.data?.connected === true;123 return (124 <Section title="Registry">125 <div className="flex flex-col gap-3 p-4">126 <div className="relative max-w-md">127 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted" aria-hidden />128 <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the skills registry" aria-label="Search the skills registry" className="pl-8" disabled={q.isSuccess && !connected} />129 </div>130 {q.isPending ? <Spinner className="size-3.5" />131 : q.isError ? <p className="text-xs text-error">{errorMessage(q.error)}</p>132 : !connected ? (133 <p className="flex items-center gap-2 text-xs text-text-2" data-testid="registry-disconnected"><Unplug className="size-3.5 text-muted" /> Registry not connected. Installed skills are still listed below; search and update checks are unavailable.</p>134 ) : !debounced ? (135 <p className="text-xs text-muted">Type to search the registry.</p>136 ) : (q.data?.results?.length ?? 0) === 0 ? (137 <p className="text-xs text-muted">No registry results</p>138 ) : (139 <ul className="divide-y divide-line rounded-md border border-line-2">140 {q.data?.results?.map((r) => (141 <li key={r.name} className="px-3 py-2 text-xs">142 <span className="font-medium text-text">{r.name}</span> <span className="text-muted">{[r.type, r.latestVersion, r.publisher, r.license].filter(Boolean).join(' · ')}</span>143 {r.verified && <Badge tone="success" className="ml-2">verified</Badge>}144 <span className="block text-text-2">{r.summary}</span>145 </li>146 ))}147 </ul>148 )}149 </div>150 </Section>151 );152}153