diff --git a/apps/web/src/hooks/use-org-fs.ts b/apps/web/src/hooks/use-org-fs.ts index ffd26ff5b9..073afe44dc 100644 --- a/apps/web/src/hooks/use-org-fs.ts +++ b/apps/web/src/hooks/use-org-fs.ts @@ -109,6 +109,23 @@ export function useOrgFsList(volume: string, path: string) { }); } +/** Metadata for one entry — null when absent. Imperative twin of {@link useOrgFsStat}. */ +export async function fetchOrgFsStat( + orgSlug: string, + volume: string, + path: string, +): Promise { + const res = await fsFetch( + fsUrl(orgSlug, volume, "stat", { path }), + undefined, + { + allow404: true, + }, + ); + if (res.status === 404) return null; + return ((await res.json()) as { entry: OrgFsEntry }).entry; +} + /** Metadata for one entry — null when absent. Powers the Library preview. */ export function useOrgFsStat( volume: string | null, @@ -125,15 +142,7 @@ export function useOrgFsStat( ? (query) => query.state.data ? false : (opts.refetchIntervalWhenAbsent ?? false) : undefined, - queryFn: async (): Promise => { - const res = await fsFetch( - fsUrl(org.slug, volume ?? "", "stat", { path }), - undefined, - { allow404: true }, - ); - if (res.status === 404) return null; - return ((await res.json()) as { entry: OrgFsEntry }).entry; - }, + queryFn: () => fetchOrgFsStat(org.slug, volume ?? "", path), }); } @@ -273,6 +282,19 @@ export async function fetchOrgFsSkillCatalog( return skills; } +/** react-query wrapper around {@link fetchOrgFsSkillCatalog} — same cache key + * the chat "/" picker uses (`KEYS.slashSkills`), so both surfaces share one + * fetch of the org's full skill catalog (home + public sets + synced repos). */ +export function useOrgFsSkillCatalog() { + const { org } = useProjectContext(); + return useQuery({ + queryKey: KEYS.slashSkills(org.id), + queryFn: () => fetchOrgFsSkillCatalog(org.slug), + // Same window as the picker: a build rescans home + every synced volume. + staleTime: 60_000, + }); +} + /** Read a file's contents as UTF-8 text (org-fs `/read` endpoint). */ async function fetchOrgFsText( orgSlug: string, diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index a9e0deffb7..79274dd203 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -118,6 +118,7 @@ export const settings = { "settings.nav.connections": "Connections", "settings.nav.agents": "Agents", "settings.nav.automations": "Automations", + "settings.nav.skills": "Skills", "settings.nav.store": "Store", "settings.nav.monitor": "Monitor", "settings.nav.members": "Members", @@ -170,6 +171,38 @@ export const settings = { "settings.automations.noResultsTitle": "No automations found", "settings.automations.pageTitle": "Automations", "settings.automations.searchPlaceholder": "Search automations...", + "settings.skills.pageTitle": "Skills", + "settings.skills.importButton": "Import skill", + "settings.skills.importing": "Importing…", + "settings.skills.importSuccess": 'Imported "{name}"', + "settings.skills.importError": "Failed to import skill", + "settings.skills.importMissingSkillMd": + "That folder has no SKILL.md at its root. Pick the skill's own folder.", + "settings.skills.importNeedsFolder": + "Pick a folder, not individual files — this browser may not support folder upload.", + "settings.skills.searchPlaceholder": "Search skills...", + "settings.skills.noDescription": "No description", + "settings.skills.filterAll": "All", + "settings.skills.emptyTitle": "No skills yet", + "settings.skills.emptyDescription": + "Import a folder with a SKILL.md to give your agents reusable instructions they can load on demand.", + "settings.skills.noResultsTitle": "No skills found", + "settings.skills.noResultsDescription": 'No skills match "{search}"', + "settings.skills.cancel": "Cancel", + "settings.skills.deleteButton": "Delete", + "settings.skills.deleteDialogTitle": "Delete this skill?", + "settings.skills.deleteDialogDescription": + 'This removes "{name}" and its files. This can\'t be undone.', + "settings.skills.deleteSuccess": "Skill deleted", + "settings.skills.deleteError": "Failed to delete skill", + "settings.skills.importTooManyFiles": + "That folder has {count} files (limit {max}). Import a folder with just the skill's own files.", + "settings.skills.importSlugTaken": + 'A skill named "{slug}" already exists. Delete it first to re-import.', + "settings.skills.errorTitle": "Couldn't load skills", + "settings.skills.errorDescription": + "The skill catalog could not be loaded. You may not have access to this org's files.", + "settings.skills.retry": "Try again", "settings.buckets.accessKeyIdLabel": "Access key ID", "settings.buckets.addBucket": "Add bucket", "settings.buckets.addBucketButton": "Add bucket", diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 967cd0e1cd..2213e56b0f 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -122,6 +122,7 @@ export const settings = { "settings.nav.connections": "Conexões", "settings.nav.agents": "Agentes", "settings.nav.automations": "Automações", + "settings.nav.skills": "Skills", "settings.nav.store": "Loja", "settings.nav.monitor": "Monitoramento", "settings.nav.members": "Membros", @@ -175,6 +176,39 @@ export const settings = { "Nenhuma automa\u00e7\u00e3o encontrada", "settings.automations.pageTitle": "Automa\u00e7\u00f5es", "settings.automations.searchPlaceholder": "Pesquisar automa\u00e7\u00f5es...", + "settings.skills.pageTitle": "Skills", + "settings.skills.importButton": "Importar skill", + "settings.skills.importing": "Importando\u2026", + "settings.skills.importSuccess": '"{name}" importada', + "settings.skills.importError": "Falha ao importar a skill", + "settings.skills.importMissingSkillMd": + "Essa pasta n\u00e3o tem um SKILL.md na raiz. Selecione a pasta da pr\u00f3pria skill.", + "settings.skills.importNeedsFolder": + "Selecione uma pasta, não arquivos individuais — este navegador pode não suportar upload de pastas.", + "settings.skills.searchPlaceholder": "Pesquisar skills...", + "settings.skills.noDescription": "Sem descri\u00e7\u00e3o", + "settings.skills.filterAll": "Todas", + "settings.skills.emptyTitle": "Nenhuma skill ainda", + "settings.skills.emptyDescription": + "Importe uma pasta com um SKILL.md para dar aos seus agentes instru\u00e7\u00f5es reutiliz\u00e1veis que eles podem carregar sob demanda.", + "settings.skills.noResultsTitle": "Nenhuma skill encontrada", + "settings.skills.noResultsDescription": + 'Nenhuma skill corresponde a "{search}"', + "settings.skills.cancel": "Cancelar", + "settings.skills.deleteButton": "Excluir", + "settings.skills.deleteDialogTitle": "Excluir esta skill?", + "settings.skills.deleteDialogDescription": + 'Isso remove "{name}" e seus arquivos. Essa a\u00e7\u00e3o n\u00e3o pode ser desfeita.', + "settings.skills.deleteSuccess": "Skill exclu\u00edda", + "settings.skills.deleteError": "Falha ao excluir a skill", + "settings.skills.importTooManyFiles": + "Essa pasta tem {count} arquivos (limite {max}). Importe uma pasta com apenas os arquivos da skill.", + "settings.skills.importSlugTaken": + 'Já existe uma skill chamada "{slug}". Exclua-a antes de reimportar.', + "settings.skills.errorTitle": "Não foi possível carregar as skills", + "settings.skills.errorDescription": + "O catálogo de skills não pôde ser carregado. Você pode não ter acesso aos arquivos desta organização.", + "settings.skills.retry": "Tentar novamente", "settings.buckets.accessKeyIdLabel": "ID de chave de acesso", "settings.buckets.addBucket": "Adicionar bucket", "settings.buckets.addBucketButton": "Adicionar bucket", diff --git a/apps/web/src/layouts/settings-layout.tsx b/apps/web/src/layouts/settings-layout.tsx index 06792d1ad0..c80055dbba 100644 --- a/apps/web/src/layouts/settings-layout.tsx +++ b/apps/web/src/layouts/settings-layout.tsx @@ -37,6 +37,7 @@ import { ChevronDown, ChevronRight, Columns03, + Stars01, ZapSquare, CreditCard01, Loading01, @@ -179,6 +180,12 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { to: "/$org/settings/automations", requires: "automations:manage", }, + { + key: "skills", + label: t("settings.nav.skills"), + icon: , + to: "/$org/settings/skills", + }, ], }, { diff --git a/apps/web/src/react.d.ts b/apps/web/src/react.d.ts new file mode 100644 index 0000000000..db9b326c98 --- /dev/null +++ b/apps/web/src/react.d.ts @@ -0,0 +1,19 @@ +/** + * React type augmentations. + * + * Its own file, not `globals.d.ts`: that one is a global script, where + * `declare module "react"` would declare an ambient module that SHADOWS the + * real one (erasing every React export) instead of merging with it. The + * top-level `import` here makes this a module, so the block below is a module + * augmentation and merges as intended. + */ + +import "react"; + +declare module "react" { + interface InputHTMLAttributes { + /** Directory picking — supported by every browser we target, but missing + * from React's DOM attribute types. */ + webkitdirectory?: string; + } +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index f375e26a11..9cf9870883 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -917,6 +917,14 @@ const settingsAutomationsRoute = createRoute({ ), }); +const settingsSkillsRoute = createRoute({ + getParentRoute: () => settingsLayout, + path: "/skills", + component: lazyRouteComponent( + () => import("./routes/orgs/settings/skills.tsx"), + ), +}); + // ============================================ // ROUTE TREE // ============================================ @@ -928,6 +936,7 @@ const settingsWithChildren = settingsLayout.addChildren([ collectionDetailRoute, settingsAgentsRoute, settingsAutomationsRoute, + settingsSkillsRoute, monitoringRoute, settingsGeneralRoute, settingsConnectRoute, diff --git a/apps/web/src/routes/orgs/settings/skills-import.test.ts b/apps/web/src/routes/orgs/settings/skills-import.test.ts new file mode 100644 index 0000000000..ce78fc5e6b --- /dev/null +++ b/apps/web/src/routes/orgs/settings/skills-import.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + groupByDestination, + importable, + relativePath, + slugify, + uploadAllGroups, +} from "./skills-import.ts"; + +/** A picked file, standing in for what a directory `` hands back. */ +function picked(webkitRelativePath: string): File { + const name = webkitRelativePath.split("/").pop() ?? ""; + const file = new File(["x"], name); + Object.defineProperty(file, "webkitRelativePath", { + value: webkitRelativePath, + }); + return file; +} + +describe("relativePath", () => { + test("strips the picked folder root", () => { + expect(relativePath(picked("my-skill/SKILL.md"))).toBe("SKILL.md"); + expect(relativePath(picked("my-skill/references/style.md"))).toBe( + "references/style.md", + ); + }); + + test("falls back to the bare name on a non-directory pick", () => { + // What a plain file input reports: a bare name, no folder root to strip. + expect(relativePath(picked("SKILL.md"))).toBe("SKILL.md"); + }); +}); + +describe("importable", () => { + test("keeps the skill's own files at any depth", () => { + expect(importable(picked("s/SKILL.md"))).toBe(true); + expect(importable(picked("s/references/deep/style.md"))).toBe(true); + }); + + test("drops dotfiles, dot-dirs and tooling dirs", () => { + expect(importable(picked("s/.DS_Store"))).toBe(false); + expect(importable(picked("s/.git/config"))).toBe(false); + expect(importable(picked("s/node_modules/left-pad/index.js"))).toBe(false); + expect(importable(picked("s/scripts/__pycache__/a.pyc"))).toBe(false); + }); +}); + +describe("slugify", () => { + test("normalizes a folder name", () => { + expect(slugify(" My Cool Skill! ")).toBe("my-cool-skill"); + }); + + test("never yields an empty slug", () => { + expect(slugify("!!!")).toBe("skill"); + expect(slugify("")).toBe("skill"); + }); +}); + +describe("groupByDestination", () => { + test("keeps subdirectories instead of flattening onto the root", () => { + const groups = groupByDestination( + [ + picked("my-skill/SKILL.md"), + picked("my-skill/references/style.md"), + picked("my-skill/references/tone.md"), + ], + "my-skill", + ); + expect([...groups.keys()].toSorted()).toEqual([ + "skills/my-skill", + "skills/my-skill/references", + ]); + expect( + groups + .get("skills/my-skill/references") + ?.map((f) => f.name) + .toSorted(), + ).toEqual(["style.md", "tone.md"]); + }); +}); + +describe("uploadAllGroups", () => { + const groups = new Map([ + ["skills/s", [new File([], "SKILL.md")]], + ["skills/s/a", [new File([], "a.md")]], + ["skills/s/b", [new File([], "b.md")]], + ]); + + test("uploads every group", async () => { + const seen: string[] = []; + await uploadAllGroups(groups, async ({ dir }) => void seen.push(dir)); + expect(seen.toSorted()).toEqual(["skills/s", "skills/s/a", "skills/s/b"]); + }); + + test("waits for the other groups before rejecting", async () => { + const landed: string[] = []; + const put = async ({ dir }: { dir: string }) => { + if (dir === "skills/s") throw new Error("quota"); + await new Promise((r) => setTimeout(r, 5)); + landed.push(dir); + }; + await expect(uploadAllGroups(groups, put)).rejects.toThrow("quota"); + // Not the `Promise.all` behaviour: the slow PUTs are done, not in flight. + expect(landed.toSorted()).toEqual(["skills/s/a", "skills/s/b"]); + }); +}); diff --git a/apps/web/src/routes/orgs/settings/skills-import.ts b/apps/web/src/routes/orgs/settings/skills-import.ts new file mode 100644 index 0000000000..8770d92297 --- /dev/null +++ b/apps/web/src/routes/orgs/settings/skills-import.ts @@ -0,0 +1,72 @@ +/** + * Pure mapping from a picked folder onto `home/skills//…`, split from the + * page so it can be unit-tested without the route's React module graph. + */ + +/** One PUT per file, so a stray `node_modules` would fan out to thousands. */ +export const MAX_IMPORT_FILES = 200; + +const SKIPPED_DIRS = new Set(["node_modules", "__pycache__"]); + +/** A picked file's path relative to the folder root the user chose. */ +export function relativePath(file: File): string { + const [, ...rest] = file.webkitRelativePath.split("/"); + return rest.length > 0 ? rest.join("/") : file.name; +} + +/** Drop what a skill folder never means to ship: tooling dirs and dotfiles. */ +export function importable(file: File): boolean { + return relativePath(file) + .split("/") + .every((segment) => !segment.startsWith(".") && !SKIPPED_DIRS.has(segment)); +} + +export function slugify(name: string): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "skill" + ); +} + +/** + * Group the picked files by destination directory: the upload endpoint takes + * one directory plus files whose own `name` completes the path, so nested + * files must be grouped rather than flattened (which would collapse + * `references/style.md` onto the root). + */ +export function groupByDestination( + files: File[], + slug: string, +): Map { + const groups = new Map(); + for (const file of files) { + const segments = relativePath(file).split("/"); + segments.pop(); + const dir = ["skills", slug, ...segments].join("/"); + groups.set(dir, [...(groups.get(dir) ?? []), file]); + } + return groups; +} + +/** + * Upload every destination group concurrently. + * + * `allSettled`, not `all`: `all` rejects on the first failure while the rest + * are still in flight, resolving the caller's catch (and its catalog refresh) + * before those PUTs land — the same trap `useOrgFsMutations` avoids per-file. + */ +export async function uploadAllGroups( + groups: Map, + put: (input: { dir: string; files: File[] }) => Promise, +): Promise { + const results = await Promise.allSettled( + [...groups].map(([dir, files]) => put({ dir, files })), + ); + const failure = results.find( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + if (failure) throw failure.reason; +} diff --git a/apps/web/src/routes/orgs/settings/skills.tsx b/apps/web/src/routes/orgs/settings/skills.tsx new file mode 100644 index 0000000000..8efdc0d44c --- /dev/null +++ b/apps/web/src/routes/orgs/settings/skills.tsx @@ -0,0 +1,493 @@ +/** + * Settings → Build → Skills: an org-wide view of every skill available to the + * org's agents (the same catalog `` surfaces at runtime — + * see `useOrgFsSkillCatalog`), plus importing new ones. + * + * Skills are just `SKILL.md` folders on the org filesystem, so importing one is + * the Library's upload with the skill format enforced: pick a folder containing + * a `SKILL.md` and its files land under `home/skills//…`, subdirectories + * intact. Public sets and synced-repo skills are read-only. + */ + +import { useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + AlertTriangle, + DotsVertical, + GitBranch01, + Package, + Trash01, + Upload01, + Zap, +} from "@untitledui/icons"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Card } from "@decocms/ui/components/card.tsx"; +import { SearchInput } from "@decocms/ui/components/search-input.tsx"; +import { Skeleton } from "@decocms/ui/components/skeleton.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; +import { Page } from "@/components/page"; +import { EmptyState } from "@/components/empty-state.tsx"; +import { CollectionTabs } from "@/components/collections/collection-tabs.tsx"; +import { FolderIcon } from "@/components/folder-icon"; +import { useProjectContext } from "@/sdk"; +import { useT } from "@/i18n/use-t.ts"; +import { KEYS } from "@/lib/query-keys"; +import { + fetchOrgFsStat, + type OrgFsSkillCatalogEntry, + useOrgFsMutations, + useOrgFsSkillCatalog, +} from "@/hooks/use-org-fs"; +import { browsePathForEntry } from "@/layouts/library/location"; +import { SkillPreviewDialog } from "@/layouts/library/skill-preview"; +import { + groupByDestination, + importable, + MAX_IMPORT_FILES, + relativePath, + slugify, + uploadAllGroups, +} from "./skills-import.ts"; + +/** Filter-chip id for "no origin filter". Not a real `source`, so it can't + * collide with one. */ +const ALL_SOURCES = "*"; + +/** Home-volume skills are the org's own — editable. Everything else (public + * sets, synced repos) is read-only here, same as in the Library. */ +function isEditable(entry: OrgFsSkillCatalogEntry): boolean { + return entry.volume === "home"; +} + +/** + * Where a skill comes from — the one axis that actually varies across the + * catalog, so it names the filter chips and marks the folder body. + * + * The catalog's raw `source` is a wire token (`home`, `public:core`, + * `repo:docs`) which doubles as a stable filter id, but a member has no reason + * to read it: the prefix is dropped and `home` resolves to the org's own name. + */ +function skillOrigin(source: string, orgName: string) { + const separator = source.indexOf(":"); + if (separator === -1) return { label: orgName, glyph: Zap }; + return { + label: source.slice(separator + 1), + glyph: source.startsWith("repo:") ? GitBranch01 : Package, + }; +} + +/** + * One skill, wearing the Library's folder: same icon a member already + * associates with these directories, and the same palette rule — Finder blue + * for folders people make, graphite plus a view-only badge for the ones the + * product fills. The body glyph names the origin (set, repo, your own). + */ +function SkillCard({ + entry, + onOpen, + onDelete, +}: { + entry: OrgFsSkillCatalogEntry; + onOpen: () => void; + onDelete?: () => void; +}) { + const t = useT(); + const { org } = useProjectContext(); + const { label, glyph } = skillOrigin(entry.source, org.name); + // The predicate that gates delete, so the badge can't contradict the menu. + const editable = isEditable(entry); + + return ( + + {/* Overlay button — the whole card opens the preview */} + + + + { + e.stopPropagation(); + onDelete(); + }} + > + + {t("settings.skills.deleteButton")} + + + + + )} + + +
+

+ {entry.name} +

+

+ {entry.description || t("settings.skills.noDescription")} +

+
+ + +
+
+

{label}

+
+
+ +
+ ); +} + +function SkillsGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export default function SettingsSkillsPage() { + const t = useT(); + const { org } = useProjectContext(); + const catalog = useOrgFsSkillCatalog(); + const { remove, upload } = useOrgFsMutations("home"); + const queryClient = useQueryClient(); + const folderInputRef = useRef(null); + + const [search, setSearch] = useState(""); + const [source, setSource] = useState(ALL_SOURCES); + const [importing, setImporting] = useState(false); + const [previewPath, setPreviewPath] = useState(null); + const [pendingDelete, setPendingDelete] = + useState(null); + + const refreshCatalog = () => { + queryClient.invalidateQueries({ queryKey: KEYS.slashSkills(org.id) }); + queryClient.invalidateQueries({ queryKey: KEYS.orgFsSkills(org.id) }); + }; + + async function handleImport(fileList: FileList | null) { + const picked = [...(fileList ?? [])]; + // Reset first: picking the same folder twice must re-fire `change`. + if (folderInputRef.current) folderInputRef.current.value = ""; + if (picked.length === 0) return; + + // Empty means the browser ignored `webkitdirectory` and gave a flat pick, + // which would flatten the skill's subdirectories onto its root. + const root = picked[0]?.webkitRelativePath ?? ""; + if (!root) { + toast.error(t("settings.skills.importNeedsFolder")); + return; + } + + const folder = root.split("/")[0] ?? ""; + const files = picked.filter(importable); + if (!files.some((f) => relativePath(f) === "SKILL.md")) { + toast.error(t("settings.skills.importMissingSkillMd")); + return; + } + if (files.length > MAX_IMPORT_FILES) { + toast.error( + t("settings.skills.importTooManyFiles", { + count: String(files.length), + max: String(MAX_IMPORT_FILES), + }), + ); + return; + } + + const slug = slugify(folder); + const dir = `skills/${slug}`; + // Only true once the probe below proved the tree ours to roll back. + let created = false; + setImporting(true); + try { + // Merging would leave the existing skill's unmatched files behind. + if (await fetchOrgFsStat(org.slug, "home", dir)) { + toast.error(t("settings.skills.importSlugTaken", { slug })); + return; + } + created = true; + await uploadAllGroups( + groupByDestination(files, slug), + upload.mutateAsync, + ); + refreshCatalog(); + toast.success(t("settings.skills.importSuccess", { name: slug })); + } catch (err) { + // A half-written tree would serve agents a broken skill, and its bare + // directory would block the retry on the slug probe above. + if (created) await remove.mutateAsync(dir).catch(() => {}); + refreshCatalog(); + toast.error( + err instanceof Error ? err.message : t("settings.skills.importError"), + ); + } finally { + setImporting(false); + } + } + + const lowerSearch = search.toLowerCase(); + // The org's own skills lead; the rest follow alphabetically. + const matching = (catalog.data ?? []) + .filter( + (e) => + e.name.toLowerCase().includes(lowerSearch) || + (e.description ?? "").toLowerCase().includes(lowerSearch), + ) + .sort( + (a, b) => + Number(isEditable(b)) - Number(isEditable(a)) || + a.name.localeCompare(b.name), + ); + + // Chips come from the whole catalog; only their counts narrow with the search. + const allSources = new Set((catalog.data ?? []).map((e) => e.source)); + const counts = new Map(); + for (const e of matching) { + counts.set(e.source, (counts.get(e.source) ?? 0) + 1); + } + const tabs = [ + { + id: ALL_SOURCES, + label: t("settings.skills.filterAll"), + count: matching.length, + }, + ...[...allSources].map((entrySource) => ({ + id: entrySource, + label: skillOrigin(entrySource, org.name).label, + count: counts.get(entrySource) ?? 0, + })), + ]; + + // Only a source the catalog no longer has at all falls back to All. + const activeSource = allSources.has(source) ? source : ALL_SOURCES; + const filtered = + activeSource === ALL_SOURCES + ? matching + : matching.filter((e) => e.source === activeSource); + + const openPreview = (entry: OrgFsSkillCatalogEntry) => + setPreviewPath(browsePathForEntry(entry.volume, entry.path)); + + const confirmDelete = async () => { + if (!pendingDelete) return; + const { path } = pendingDelete; + setPendingDelete(null); + try { + await remove.mutateAsync(path); + refreshCatalog(); + toast.success(t("settings.skills.deleteSuccess")); + } catch (err) { + toast.error( + err instanceof Error ? err.message : t("settings.skills.deleteError"), + ); + } + }; + + const importButton = ( + + ); + + return ( + + + + {/* Title, toolbar, chips and results are siblings of one gap-6 + column — the Connections page's rhythm. */} +
+ {t("settings.skills.pageTitle")} +
+ { + if (event.key === "Escape") { + setSearch(""); + (event.target as HTMLInputElement).blur(); + } + }} + /> + {importButton} + void handleImport(e.target.files)} + /> +
+ + {/* One origin means the chips can only say "All" — hide them. */} + {tabs.length > 2 && ( + + )} + + {catalog.isPending ? ( +
+ + {Array.from({ length: 8 }, (_, i) => ( + + ))} + +
+ ) : catalog.isError ? ( + /* Never the empty state: "no skills yet" is a different fact. */ +
+ + } + title={t("settings.skills.errorTitle")} + description={ + catalog.error instanceof Error + ? catalog.error.message + : t("settings.skills.errorDescription") + } + actions={ + + } + /> +
+ ) : filtered.length === 0 ? ( +
+ } + title={ + search + ? t("settings.skills.noResultsTitle") + : t("settings.skills.emptyTitle") + } + description={ + search + ? t("settings.skills.noResultsDescription", { search }) + : t("settings.skills.emptyDescription") + } + actions={!search && importButton} + /> +
+ ) : ( +
+ + {filtered.map((entry) => ( + openPreview(entry)} + onDelete={ + isEditable(entry) + ? () => setPendingDelete(entry) + : undefined + } + /> + ))} + +
+ )} +
+
+
+ + {previewPath && ( + setPreviewPath(null)} + /> + )} + + !open && setPendingDelete(null)} + > + + + + {t("settings.skills.deleteDialogTitle")} + + + {t("settings.skills.deleteDialogDescription", { + name: pendingDelete?.name ?? "", + })} + + + + {t("settings.skills.cancel")} + + {t("settings.skills.deleteButton")} + + + + +
+ ); +}