feat(webui): add skills marketplace

This commit is contained in:
Xubin Ren
2026-07-30 01:13:37 +08:00
parent 129b74b4cf
commit ba0ba4749d
23 changed files with 3042 additions and 246 deletions
@@ -1,25 +1,89 @@
import { useEffect, useState, type ReactNode } from "react";
import type { TFunction } from "i18next";
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
import {
Check,
CircleAlert,
Copy,
KeyRound,
Loader2,
PowerOff,
RefreshCw,
Search,
Terminal,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { fetchSkillDetail } from "@/lib/api";
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
import { notifySkillsChanged } from "@/lib/skill-events";
import type { SkillDetail, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter((skill) => skill.available).length;
const availableCount = skills.filter(
(skill) => skill.enabled !== false && skill.available,
).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
const [view, setView] = useState<"installed" | "discover">("installed");
const [installedQuery, setInstalledQuery] = useState("");
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
"all",
);
const normalizedQuery = installedQuery.trim().toLowerCase();
const filteredSkills = skills.filter((skill) => {
const enabled = skill.enabled !== false;
if (installedFilter === "enabled" && !enabled) return false;
if (installedFilter === "disabled" && enabled) return false;
return (
!normalizedQuery
|| skill.name.toLowerCase().includes(normalizedQuery)
|| skill.description.toLowerCase().includes(normalizedQuery)
);
});
const groupedSkills = [
{
key: "workspace",
label: t("settings.skills.customGroup", { defaultValue: "Custom" }),
skills: filteredSkills.filter((skill) => skill.source === "workspace"),
},
{
key: "builtin",
label: t("settings.skills.builtinGroup", { defaultValue: "Built-in" }),
skills: filteredSkills.filter((skill) => skill.source === "builtin"),
},
{
key: "other",
label: t("settings.skills.otherGroup", { defaultValue: "Other" }),
skills: filteredSkills.filter(
(skill) => skill.source !== "workspace" && skill.source !== "builtin",
),
},
].filter((group) => group.skills.length);
const disabledCount = skills.filter((skill) => skill.enabled === false).length;
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
defaultValue:
"Review installed skills or discover new capabilities from the skills.sh catalog.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
@@ -31,31 +95,122 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
</span>
</section>
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
<div
className="inline-flex rounded-[12px] bg-muted/65 p-1"
role="tablist"
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
>
{(["installed", "discover"] as const).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={view === item}
onClick={() => setView(item)}
className={cn(
"inline-flex items-center gap-1.5 rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
view === item
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{item === "installed"
? t("settings.skills.installedTab", { defaultValue: "Installed" })
: (
<>
<VercelMark />
{t("settings.skills.discoverTab", { defaultValue: "Discover" })}
</>
)}
</button>
))}
</div>
{view === "installed" ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-[320px]">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
))}
<Input
value={installedQuery}
onChange={(event) => setInstalledQuery(event.target.value)}
placeholder={t("settings.skills.searchInstalled", {
defaultValue: "Search installed skills",
})}
aria-label={t("settings.skills.searchInstalled", {
defaultValue: "Search installed skills",
})}
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/>
</div>
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1">
{([
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
[
"enabled",
t("settings.skills.filterEnabled", { defaultValue: "Enabled" }),
skills.length - disabledCount,
],
[
"disabled",
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
disabledCount,
],
] as const).map(([filter, label, count]) => (
<button
key={filter}
type="button"
onClick={() => setInstalledFilter(filter)}
className={cn(
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</button>
))}
</div>
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
{groupedSkills.length ? (
<div className="pb-2">
{groupedSkills.map((group) => (
<section key={group.key}>
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</h2>
<span className="text-[11px] tabular-nums text-muted-foreground/60">
{group.skills.length}
</span>
</div>
<div className="divide-y divide-border/40 px-3 sm:px-4">
{group.skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.noMatching", {
defaultValue: "No matching skills.",
})}
</div>
)}
</section>
) : (
<SkillsMarketplace installedSkills={skills} />
)}
<SkillDetailSheet
skill={selectedSkill}
@@ -68,6 +223,14 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
);
}
function VercelMark() {
return (
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden>
<path d="M8 1 16 15H0L8 1Z" fill="currentColor" />
</svg>
);
}
function SkillCatalogRow({
skill,
onSelect,
@@ -76,11 +239,13 @@ function SkillCatalogRow({
onSelect: (skill: SkillSummary) => void;
}) {
const { t } = useTranslation();
const sourceLabel = skillSourceLabel(skill.source, t);
const StatusIcon = skill.available ? Check : CircleAlert;
const statusLabel = skill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
const enabled = skill.enabled !== false;
const StatusIcon = !enabled ? PowerOff : skill.available ? Check : CircleAlert;
const statusLabel = !enabled
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
: skill.available
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
return (
<button
@@ -91,42 +256,28 @@ function SkillCatalogRow({
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
!enabled && "opacity-60",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
title={enabled && !skill.available ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
"hidden w-[92px] shrink-0 items-center justify-end gap-1 text-[11px] font-medium sm:inline-flex",
!enabled
? "text-muted-foreground"
: skill.available
? "text-emerald-700 dark:text-emerald-300"
: "text-amber-700 dark:text-amber-300",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
@@ -150,6 +301,10 @@ function SkillDetailSheet({
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (!open || !skill) return;
@@ -157,6 +312,8 @@ function SkillDetailSheet({
setDetail(null);
setLoading(true);
setLoadFailed(false);
setActionError("");
setDeleteOpen(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
@@ -170,90 +327,228 @@ function SkillDetailSheet({
return () => {
cancelled = true;
};
}, [open, skill, token]);
}, [open, refreshKey, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const enabled = activeSkill.enabled !== false;
const deletable = activeSkill.deletable ?? activeSkill.source === "workspace";
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
const statusLabel = !enabled
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
: activeSkill.available
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
const toggleEnabled = async () => {
setActionBusy(true);
setActionError("");
try {
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) {
setDetail((current) => current ? { ...current, ...updated } : current);
}
} catch (reason) {
setActionError(
reason instanceof Error
? reason.message
: t("settings.skills.updateFailed", {
defaultValue: "Could not update this skill.",
}),
);
} finally {
setActionBusy(false);
}
};
const removeSkill = async () => {
setDeleteOpen(false);
setActionBusy(true);
setActionError("");
try {
const payload = await deleteSkill(token, activeSkill.name);
notifySkillsChanged(payload);
onOpenChange(false);
} catch (reason) {
setActionError(
reason instanceof Error
? reason.message
: t("settings.skills.deleteFailed", {
defaultValue: "Could not delete this skill.",
}),
);
} finally {
setActionBusy(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill
tone={
!enabled
? "muted"
: activeSkill.available
? "success"
: "warning"
}
>
{statusLabel}
</Pill>
</div>
<p className="mt-3 text-[13px] leading-5 text-muted-foreground">
{activeSkill.description}
</p>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-6 space-y-5">
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.enabledDescription", {
defaultValue:
"Allow the agent to load this skill when its requirements are ready.",
})}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={t("settings.skills.toggleEnabled", {
name: activeSkill.name,
defaultValue: enabled ? "Disable {{name}}" : "Enable {{name}}",
})}
disabled={actionBusy}
onClick={() => void toggleEnabled()}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-wait disabled:opacity-60",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
</div>
{actionError ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{actionError}
</div>
) : null}
{detail && enabled ? (
<RequirementsSection
detail={detail}
onRefresh={() => setRefreshKey((value) => value + 1)}
/>
) : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
{deletable ? (
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.deleteDescription", {
defaultValue: "Remove this skill from the current workspace.",
})}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={actionBusy}
onClick={() => setDeleteOpen(true)}
className="shrink-0 rounded-full"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.deleteAction", { defaultValue: "Delete" })}
</Button>
</div>
) : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.skills.deleteConfirmTitle", {
name: activeSkill.name,
defaultValue: "Delete {{name}}?",
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.skills.deleteConfirmDescription", {
defaultValue:
"This removes the skill files from the current workspace. This action cannot be undone.",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", { defaultValue: "Cancel" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => void removeSkill()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("settings.skills.deleteConfirmAction", { defaultValue: "Delete skill" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -267,8 +562,13 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
</span>
<code className="font-mono text-[10px] font-normal text-muted-foreground">
SKILL.md
</code>
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
@@ -287,100 +587,131 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
function RequirementsSection({ detail }: { detail: SkillDetail }) {
function RequirementsSection({
detail,
onRefresh,
}: {
detail: SkillDetail;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const { bins, env, missing_bins, missing_env } = detail.requirements;
const hasRequirements = bins.length > 0 || env.length > 0;
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const { missing_bins, missing_env } = detail.requirements;
const hasMissing = missing_bins.length > 0 || missing_env.length > 0;
if (!hasMissing) return null;
const installOptions = detail.install_options ?? [];
const copyCommand = async (command: string) => {
try {
await navigator.clipboard.writeText(command);
setCopiedCommand(command);
} catch {
setCopiedCommand(null);
}
};
return (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
<div className="flex items-start gap-2.5">
<CircleAlert
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
aria-hidden
/>
<div className="min-w-0 flex-1">
<h3 className="text-[13px] font-semibold text-foreground">
{t("settings.skills.setupRequired", { defaultValue: "Setup required" })}
</h3>
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.setupDescription", {
defaultValue:
"Install the missing dependency on the machine running nanobot, then check again.",
})}
</p>
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
</div>
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
<div className="mt-3 space-y-2">
{installOptions.map((option) => (
<div
key={`${option.id}:${option.command}`}
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
>
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
{option.command}
</code>
<button
type="button"
aria-label={t("settings.skills.copySetupCommand", {
defaultValue: "Copy setup command",
})}
title={option.label}
onClick={() => void copyCommand(option.command)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</div>
))}
{!installOptions.length && missing_bins.length ? (
<SetupRequirement
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
label={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
/>
) : null}
{missing_env.length ? (
<SetupRequirement
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
label={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
/>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.checkAgain", { defaultValue: "Check again" })}
</Button>
</section>
);
}
function RequirementLine({
title,
function SetupRequirement({
label,
items,
icon,
tone = "muted",
}: {
title: string;
label: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
<div className="flex flex-wrap items-center gap-1.5 px-1 py-1 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
{label}
</span>
{items.map((item) => (
<code
key={item}
className="rounded-full bg-background/80 px-2 py-0.5 font-mono text-[10px] text-foreground/70"
>
{item}
</code>
))}
</div>
);
}
@@ -390,7 +721,7 @@ function Pill({
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
tone?: "muted" | "success" | "warning";
}) {
return (
<span
@@ -398,7 +729,9 @@ function Pill({
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
: tone === "warning"
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
@@ -0,0 +1,520 @@
import { useEffect, useMemo, useState } from "react";
import {
Check,
Download,
ExternalLink,
Loader2,
Search,
ShieldAlert,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
fetchMarketplaceSkillTrends,
fetchTrendingMarketplaceSkills,
installMarketplaceSkill,
searchMarketplaceSkills,
} from "@/lib/api";
import { notifySkillsChanged } from "@/lib/skill-events";
import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) {
const { token } = useClient();
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
const [trending, setTrending] = useState<MarketplaceSkillSummary[]>([]);
const [trends, setTrends] = useState<Record<string, number[]>>({});
const [loading, setLoading] = useState(false);
const [trendingLoading, setTrendingLoading] = useState(true);
const [error, setError] = useState("");
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
const [installing, setInstalling] = useState("");
const installedNames = useMemo(
() => new Set(installedSkills.map((skill) => skill.name)),
[installedSkills],
);
useEffect(() => {
let cancelled = false;
setTrendingLoading(true);
fetchTrendingMarketplaceSkills(token)
.then((payload) => {
if (cancelled) return;
setTrending(payload.skills);
setInstallSupported(payload.install_supported);
})
.catch(() => {
if (!cancelled) setTrending([]);
})
.finally(() => {
if (!cancelled) setTrendingLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
const skills = query.trim().length < 2 ? trending : results;
const unresolved = skills.filter((skill) => !(skill.id in trends));
if (!unresolved.length) return;
let cancelled = false;
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
.then((payload) => {
if (!cancelled) {
setTrends((current) => ({ ...current, ...payload.trends }));
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [query, results, token, trending, trends]);
useEffect(() => {
const normalized = query.trim();
if (normalized.length < 2) {
setResults([]);
setLoading(false);
setError("");
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setLoading(true);
setError("");
searchMarketplaceSkills(token, normalized)
.then((payload) => {
if (cancelled) return;
setResults(payload.skills);
setInstallSupported(payload.install_supported);
})
.catch((reason: unknown) => {
if (cancelled) return;
setResults([]);
setError(
reason instanceof Error
? reason.message
: t("settings.skills.marketplaceSearchFailed", {
defaultValue: "Could not search skills.sh.",
}),
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [query, t, token]);
const install = async (skill: MarketplaceSkillSummary) => {
setSelected(null);
setInstalling(skill.skill_id);
setError("");
try {
const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id);
notifySkillsChanged(payload);
setResults((current) =>
current.map((item) =>
item.id === skill.id ? { ...item, installed: true } : item,
),
);
setTrending((current) =>
current.map((item) =>
item.id === skill.id ? { ...item, installed: true } : item,
),
);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t("settings.skills.marketplaceInstallFailed", {
defaultValue: "Could not install this skill.",
}),
);
} finally {
setInstalling("");
}
};
return (
<section className="space-y-4">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
defaultValue: "Search skills.sh",
})}
aria-label={t("settings.skills.marketplaceSearchLabel", {
defaultValue: "Search skills.sh",
})}
className="h-11 rounded-[14px] bg-settings-surface pl-9"
/>
{loading ? (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
role="status"
aria-label={t("settings.skills.marketplaceSearching", {
defaultValue: "Searching",
})}
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
</span>
) : null}
</div>
{error ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{error}
</div>
) : null}
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending today",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue:
"Most installed across sources in 24h · curves show the 8-week trend",
})}
</p>
</div>
<a
href="https://skills.sh/trending"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
</div>
{trendingLoading ? (
<TrendingSkeleton />
) : trending.length ? (
<MarketplaceSkillList
skills={trending}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="24h"
trends={trends}
onSelect={setSelected}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingUnavailable", {
defaultValue: "Trending skills are temporarily unavailable.",
})}
</div>
)}
</section>
) : !loading && results.length === 0 && !error ? (
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.marketplaceEmpty", {
query: query.trim(),
defaultValue: "No skills found for “{{query}}”.",
})}
</div>
) : (
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<MarketplaceSkillList
skills={results}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="total"
trends={trends}
onSelect={setSelected}
/>
</div>
)}
<AlertDialog
open={selected !== null}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
<ShieldAlert className="h-5 w-5" aria-hidden />
</div>
<AlertDialogTitle>
{t("settings.skills.marketplaceConfirmTitle", {
name: selected?.name ?? "",
defaultValue: "Install {{name}}?",
})}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<span className="block">
{t("settings.skills.marketplaceConfirmDescription", {
source: selected?.source ?? "",
defaultValue:
"This third-party skill comes from {{source}} and may include instructions or executable scripts.",
})}
</span>
<code className="block rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
{selected?.source}
</code>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", { defaultValue: "Cancel" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selected) void install(selected);
}}
>
{t("settings.skills.marketplaceConfirmInstall", {
defaultValue: "Install skill",
})}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function MarketplaceSkillList({
skills,
installedNames,
installing,
installSupported,
metric,
trends,
onSelect,
}: {
skills: MarketplaceSkillSummary[];
installedNames: Set<string>;
installing: string;
installSupported: boolean | null;
metric: "total" | "24h";
trends: Record<string, number[]>;
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
return (
<div className="divide-y divide-border/45 px-3 sm:px-4">
{skills.map((skill) => (
<MarketplaceSkillRow
key={skill.id}
skill={skill}
installed={skill.installed || installedNames.has(skill.skill_id)}
isInstalling={installing === skill.skill_id}
installBusy={Boolean(installing)}
installSupported={installSupported}
metric={metric}
trend={trends[skill.id]}
onSelect={onSelect}
/>
))}
</div>
);
}
function MarketplaceSkillRow({
skill,
installed,
isInstalling,
installBusy,
installSupported,
metric,
trend,
onSelect,
}: {
skill: MarketplaceSkillSummary;
installed: boolean;
isInstalling: boolean;
installBusy: boolean;
installSupported: boolean | null;
metric: "total" | "24h";
trend?: number[];
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5">
{skill.rank ? (
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65">
#{skill.rank}
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-semibold text-foreground">
{skill.name}
</h3>
<a
href={skill.url}
target="_blank"
rel="noreferrer"
aria-label={t("settings.skills.marketplaceOpen", {
name: skill.name,
defaultValue: "Open {{name}} on skills.sh",
})}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
</div>
<p className="mt-1 truncate text-[12px] text-muted-foreground">
{skill.source}
<span className="mx-1.5">·</span>
{metric === "24h"
? t("settings.skills.marketplaceInstalls24h", {
count: skill.installs,
formattedCount: skill.installs.toLocaleString(),
defaultValue: "{{formattedCount}} installs / 24h",
})
: t("settings.skills.marketplaceInstalls", {
count: skill.installs,
formattedCount: skill.installs.toLocaleString(),
defaultValue: "{{formattedCount}} installs",
})}
</p>
</div>
<TrendSparkline values={trend} />
<Button
type="button"
size="sm"
variant={installed ? "secondary" : "default"}
disabled={installed || installBusy || installSupported === false}
onClick={() => onSelect(skill)}
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")}
title={
installSupported === false
? t("settings.skills.marketplaceNpxRequired", {
defaultValue: "Node.js with npx is required",
})
: undefined
}
>
{isInstalling ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : installed ? (
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isInstalling
? t("settings.skills.marketplaceInstalling", {
defaultValue: "Installing",
})
: installed
? t("settings.skills.marketplaceInstalled", {
defaultValue: "Installed",
})
: t("settings.skills.marketplaceInstall", {
defaultValue: "Install",
})}
</Button>
</div>
);
}
function TrendSparkline({ values }: { values?: number[] }) {
const { t } = useTranslation();
if (values === undefined) {
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
}
if (values.length < 2) {
return (
<span className="hidden w-24 shrink-0 text-center text-[11px] text-muted-foreground/60 sm:block">
{t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })}
</span>
);
}
const width = 96;
const height = 30;
const padding = 2;
const min = Math.min(...values);
const max = Math.max(...values);
const range = Math.max(max - min, 1);
const points = values.map((value, index) => ({
x: padding + (index / (values.length - 1)) * (width - padding * 2),
y: padding + ((max - value) / range) * (height - padding * 2),
}));
const line = points.slice(1).reduce((path, point, index) => {
const previous = points[index];
const middle = (previous.x + point.x) / 2;
return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;
}, `M ${points[0].x} ${points[0].y}`);
const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
role="img"
aria-label="8-week install trend"
>
<title>8-week install trend</title>
<path d={area} fill="currentColor" opacity="0.06" />
<path
d={line}
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrendingSkeleton() {
return (
<div className="divide-y divide-border/45 px-5" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="flex items-center gap-3 py-4">
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
</div>
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
);
}
@@ -1038,7 +1038,7 @@ export function ThreadComposer({
if (skillQuery !== null) {
const query = skillQuery.text;
return skills
.filter((skill) => skill.available)
.filter((skill) => skill.enabled !== false && skill.available)
.flatMap((skill) => {
const matchRank = skillMatchRank(skill, query);
return matchRank === null
+14 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { fetchSkills } from "@/lib/api";
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
import type { SkillSummary } from "@/lib/types";
export function useSkills(token: string): SkillSummary[] {
@@ -8,11 +9,21 @@ export function useSkills(token: string): SkillSummary[] {
useEffect(() => {
let cancelled = false;
fetchSkills(token)
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
.catch(() => !cancelled && setSkills([]));
const refresh = () => {
fetchSkills(token)
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
.catch(() => !cancelled && setSkills([]));
};
const onSkillsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
};
refresh();
window.addEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
return () => {
cancelled = true;
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
};
}, [token]);
+86
View File
@@ -26,7 +26,12 @@ import type {
SettingsUpdate,
SidebarStatePayload,
SkillDetail,
SkillActionPayload,
SkillInstallPayload,
SkillsPayload,
SkillsSearchPayload,
SkillsTrendsPayload,
SkillsTrendingPayload,
SlashCommand,
SlashCommandLifecycle,
TranscriptionSettingsUpdate,
@@ -310,6 +315,87 @@ export async function fetchSkillDetail(
);
}
export async function updateSkillEnabled(
token: string,
name: string,
enabled: boolean,
base: string = "",
): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name, enabled: String(enabled) });
return request<SkillActionPayload>(
`${base}/api/webui/skills/update?${params}`,
token,
);
}
export async function deleteSkill(
token: string,
name: string,
base: string = "",
): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name });
return request<SkillActionPayload>(
`${base}/api/webui/skills/delete?${params}`,
token,
);
}
export async function searchMarketplaceSkills(
token: string,
query: string,
base: string = "",
): Promise<SkillsSearchPayload> {
const params = new URLSearchParams({ q: query });
return request<SkillsSearchPayload>(
`${base}/api/webui/skills/search?${params}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchTrendingMarketplaceSkills(
token: string,
base: string = "",
): Promise<SkillsTrendingPayload> {
return request<SkillsTrendingPayload>(
`${base}/api/webui/skills/trending`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchMarketplaceSkillTrends(
token: string,
skillIds: string[],
base: string = "",
): Promise<SkillsTrendsPayload> {
const params = new URLSearchParams();
skillIds.forEach((id) => params.append("id", id));
return request<SkillsTrendsPayload>(
`${base}/api/webui/skills/trends?${params}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function installMarketplaceSkill(
token: string,
source: string,
skill: string,
base: string = "",
): Promise<SkillInstallPayload> {
const params = new URLSearchParams({ source, skill });
return request<SkillInstallPayload>(
`${base}/api/webui/skills/install?${params}`,
token,
undefined,
150_000,
);
}
export async function deleteSession(
token: string,
key: string,
+18
View File
@@ -0,0 +1,18 @@
import type { SkillsPayload } from "@/lib/types";
export const SKILLS_CHANGED_EVENT = "nanobot:skills-changed";
export function isSkillsPayload(value: unknown): value is SkillsPayload {
return (
!!value
&& typeof value === "object"
&& Array.isArray((value as { skills?: unknown }).skills)
);
}
export function notifySkillsChanged(payload: SkillsPayload): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<SkillsPayload>(SKILLS_CHANGED_EVENT, {
detail: payload,
}));
}
+53
View File
@@ -178,6 +178,8 @@ export interface SkillSummary {
name: string;
description: string;
source: "workspace" | "builtin" | string;
enabled?: boolean;
deletable?: boolean;
available: boolean;
unavailable_reason?: string;
}
@@ -189,13 +191,64 @@ export interface SkillRequirements {
missing_env: string[];
}
export interface SkillInstallOption {
id: string;
kind: string;
label: string;
command: string;
}
export interface SkillDetail extends SkillSummary {
requirements: SkillRequirements;
install_options?: SkillInstallOption[];
raw_markdown: string;
}
export interface SkillsPayload { skills: SkillSummary[]; }
export interface SkillActionPayload extends SkillsPayload {
last_action: {
name: string;
enabled: boolean;
deleted: boolean;
};
}
export interface MarketplaceSkillSummary {
id: string;
skill_id: string;
name: string;
source: string;
installs: number;
url: string;
installed: boolean;
rank?: number;
}
export interface SkillsSearchPayload {
query: string;
skills: MarketplaceSkillSummary[];
install_supported: boolean;
}
export interface SkillsTrendingPayload {
skills: MarketplaceSkillSummary[];
period: "24h";
install_supported: boolean;
}
export interface SkillsTrendsPayload {
trends: Record<string, number[]>;
}
export interface SkillInstallPayload extends SkillsPayload {
last_action: {
installed: boolean;
already_installed: boolean;
name: string;
};
}
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
+72
View File
@@ -5,6 +5,7 @@ import {
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
deleteSkill,
deleteModelConfiguration,
deleteSession,
fetchFilePreview,
@@ -14,6 +15,7 @@ import {
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
fetchMarketplaceSkillTrends,
fetchNanobotFeatures,
fetchProviderModels,
fetchSessionAutomations,
@@ -21,9 +23,11 @@ import {
fetchSidebarState,
fetchSkillDetail,
fetchSkills,
fetchTrendingMarketplaceSkills,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
installMarketplaceSkill,
listSessions,
listSlashCommands,
loginProviderOAuth,
@@ -35,6 +39,7 @@ import {
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
searchMarketplaceSkills,
startApiService,
stopApiService,
cancelChannelConnect,
@@ -49,6 +54,7 @@ import {
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateSkillEnabled,
updateWebSearchSettings,
validateChannel,
} from "@/lib/api";
@@ -287,6 +293,72 @@ describe("webui API helpers", () => {
);
});
it("encodes skills.sh search queries", async () => {
await searchMarketplaceSkills("tok", "React & testing");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/search?q=React+%26+testing",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches the skills.sh 24-hour leaderboard", async () => {
await fetchTrendingMarketplaceSkills("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/trending",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches skills.sh trend history independently", async () => {
await fetchMarketplaceSkillTrends("tok", [
"vercel-labs/skills/find-skills",
"acme/skills/react",
]);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills&id=acme%2Fskills%2Freact",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("encodes skills.sh install coordinates", async () => {
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("updates and deletes installed skills with encoded names", async () => {
await updateSkillEnabled("tok", "custom skill", false);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/update?name=custom+skill&enabled=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await deleteSkill("tok", "custom skill");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/delete?name=custom+skill",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
+243 -6
View File
@@ -384,20 +384,39 @@ describe("App layout", () => {
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
"/api/webui/skills": {
skills: [
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: true,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
},
"/api/webui/skills/github": {
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: true,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
requirements: {
@@ -406,8 +425,48 @@ describe("App layout", () => {
missing_bins: ["gh"],
missing_env: [],
},
install_options: [{
id: "brew",
kind: "brew",
label: "Install GitHub CLI (brew)",
command: "brew install gh",
}],
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
"/api/webui/skills/update?name=github&enabled=false": {
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: {
name: "github",
enabled: false,
deleted: false,
},
},
});
render(<App />);
@@ -419,9 +478,12 @@ describe("App layout", () => {
fireEvent.click(skillsButton);
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search installed skills" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Custom" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Built-in" })).toBeInTheDocument();
expect(screen.getByText("cron")).toBeInTheDocument();
expect(screen.getByText("github")).toBeInTheDocument();
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
expect(screen.getByText("Needs setup")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
@@ -439,11 +501,186 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
fireEvent.click(screen.getByText("Raw SKILL.md"));
expect(screen.getByText("Setup required")).toBeInTheDocument();
expect(screen.getByText("brew install gh")).toBeInTheDocument();
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument();
fireEvent.click(screen.getByText("Skill instructions"));
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
const enabledSwitch = screen.getByRole("switch", { name: "Disable github" });
expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(enabledSwitch);
await waitFor(() => {
expect(screen.getByRole("switch", { name: "Enable github" })).toHaveAttribute(
"aria-checked",
"false",
);
});
});
it("deletes a custom skill from its detail sheet", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
"/api/webui/skills": {
skills: [
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
},
"/api/webui/skills/custom-skill": {
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
requirements: {
bins: [],
env: [],
missing_bins: [],
missing_env: [],
},
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
},
"/api/webui/skills/delete?name=custom-skill": {
skills: [],
last_action: {
name: "custom-skill",
enabled: false,
deleted: true,
},
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
fireEvent.click(
await screen.findByRole("button", { name: "Open details for custom-skill" }),
);
expect(await screen.findByRole("heading", { name: "custom-skill" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.getByRole("heading", { name: "Delete custom-skill?" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete skill" }));
await waitFor(() => {
expect(
screen.queryByRole("button", { name: "Open details for custom-skill" }),
).not.toBeInTheDocument();
});
expect(screen.getByText("No matching skills.")).toBeInTheDocument();
});
it("discovers and installs a skill from skills.sh", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
"/api/webui/skills": {
skills: [
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
},
"/api/webui/skills/trending": {
period: "24h",
install_supported: true,
skills: [{
id: "vercel-labs/skills/find-skills",
skill_id: "find-skills",
name: "find-skills",
source: "vercel-labs/skills",
installs: 14_481,
url: "https://skills.sh/vercel-labs/skills/find-skills",
installed: false,
rank: 18,
}],
},
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
trends: {
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
},
},
"/api/webui/skills/search?q=React": {
query: "React",
install_supported: true,
skills: [{
id: "acme/agent-skills/react-testing",
skill_id: "react-testing",
name: "React Testing",
source: "acme/agent-skills",
installs: 42,
url: "https://skills.sh/acme/agent-skills/react-testing",
installed: false,
}],
},
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": {
skills: [
{
name: "react-testing",
description: "Test React apps.",
source: "workspace",
available: true,
},
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
last_action: {
installed: true,
already_installed: false,
name: "react-testing",
},
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
fireEvent.click(await screen.findByRole("tab", { name: "Discover" }));
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
expect(screen.getByText("find-skills")).toBeInTheDocument();
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
expect(
await screen.findByRole("img", { name: "8-week install trend" }),
).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), {
target: { value: "React" },
});
expect(await screen.findByText("React Testing")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install" }));
expect(
await screen.findByRole("heading", { name: "Install React Testing?" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: expect.any(String) },
}),
);
});
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("react-testing")).toBeInTheDocument();
});
it("opens Automations from the main sidebar", async () => {
+25 -6
View File
@@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={[{
name: skillName,
description: "Fetch and summarize the latest AI research papers every day",
source: "builtin",
available: true,
}]}
skills={[
{
name: skillName,
description: "Fetch and summarize the latest AI research papers every day",
source: "builtin",
enabled: true,
available: true,
},
{
name: "arxiv-disabled",
description: "Disabled research workflow",
source: "builtin",
enabled: false,
available: true,
},
{
name: "arxiv-unavailable",
description: "Unavailable research workflow",
source: "builtin",
enabled: true,
available: false,
},
]}
slashCommands={COMMANDS}
/>,
);
@@ -1499,6 +1516,8 @@ describe("ThreadComposer", () => {
const name = within(option).getByText(skillName);
expect(name).not.toHaveClass("truncate");
expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument();
expect(within(palette).queryByText("arxiv-disabled")).not.toBeInTheDocument();
expect(within(palette).queryByText("arxiv-unavailable")).not.toBeInTheDocument();
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Tab" });