SPB Git

Repository Public

page.tsx 8.8 KB 153 lines tsx

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
Files main
Menu

Theme