| 1 | /** |
| 2 | * Customize → Skills (docs.g1t.sh/guides/customize/, "Skills"). Yours: the |
| 3 | * workspace's library, every skill it wrote, imported, saved from a session, |
| 4 | * follows from a repository or got from a plugin, each saying which agents |
| 5 | * it reaches with the control that changes that; the drafts waiting for |
| 6 | * review first; g1t's foundational skills, which every agent has; and the |
| 7 | * repository the library follows. Discover: every skill in every |
| 8 | * marketplace's plugins, grouped by marketplace; Add installs the plugin |
| 9 | * that brings it, then the skill is given to agents like any other. |
| 10 | */ |
| 11 | import { BookOpen, ChevronRight, GitBranch, Plus, RefreshCw } from "lucide-react"; |
| 12 | import { Link, data, useFetcher, useSearchParams } from "react-router"; |
| 13 | |
| 14 | import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION, type LibrarySkill, type SkillLibrary, type SkillListing, type SkillMirror } from "@g1t/contracts"; |
| 15 | import { customizePath } from "@g1t/contracts/customize"; |
| 16 | |
| 17 | import type { Route } from "./+types/skills"; |
| 18 | import { useCustomize } from "./layout"; |
| 19 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 20 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; |
| 21 | import { FromRepository, NeedsComputer, originText, skillsPath } from "../../../components/agents/skills"; |
| 22 | import { AvailabilityBadge, AvailableTo, FeaturedBanner, FilterMenu, ListingCard, ListingGrid, ListingMark, NoAnswer, RequestButton, SectionHead, Waiting } from "../../../components/customize"; |
| 23 | import { ButtonLink, EmptyState, SubmitButton, TimeAgo } from "../../../components/ui"; |
| 24 | import { Badge } from "../../../components/ui/badge"; |
| 25 | import { Button } from "../../../components/ui/button"; |
| 26 | import { Card } from "../../../components/ui/card"; |
| 27 | import { Input } from "../../../components/ui/input"; |
| 28 | import { type Reach, type ReachOption, categoriesOf, filterSkills, groupByMarketplace, nextParams, pluginPath, readQuery, readView, requestedBy, skillListingRef, splitTarget, waitingFor, withParams } from "../../../lib/customize"; |
| 29 | import { page } from "../../../lib/meta"; |
| 30 | import { skillLibrary, workspaceAgents } from "../../../lib/services.server"; |
| 31 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 32 | |
| 33 | export { shouldRevalidate } from "../../../lib/customize-revalidate"; |
| 34 | |
| 35 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 36 | return page(args, { title: `Skills · Customize · ${params.owner} · g1t` }); |
| 37 | } |
| 38 | |
| 39 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ library: SkillLibrary | null; discover: SkillListing[] | null | undefined }> { |
| 40 | const viewer = requireUser(context, request); |
| 41 | const slug = params.owner.toLowerCase(); |
| 42 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 43 | const discovering = readView(new URL(request.url).searchParams) === "discover"; |
| 44 | const [library, discover] = await Promise.all([ |
| 45 | readOrNull(skillLibrary.library(slug, viewer)), |
| 46 | // Only Discover reads the marketplaces; undefined says it wasn't asked for. |
| 47 | discovering ? readOrNull(workspaceAgents.discover(slug, viewer)).then((found) => found?.skills ?? null) : Promise.resolve(undefined), |
| 48 | ]); |
| 49 | return { library, discover }; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Give a skill to every agent, a team or one agent, or take it back; move |
| 54 | * an attachment to the newest version; install the plugin that brings a |
| 55 | * skill from Discover; link, read again or unlink the repository the |
| 56 | * library follows. |
| 57 | */ |
| 58 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 59 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); |
| 60 | const intent = String(form.get("intent") ?? ""); |
| 61 | const name = String(form.get("name") ?? ""); |
| 62 | switch (intent) { |
| 63 | case "attach": { |
| 64 | const { scope, target } = splitTarget(String(form.get("where") ?? "")); |
| 65 | return answer(intent, skillLibrary.attachSkill(slug, viewer, name, scope, target)); |
| 66 | } |
| 67 | case "detach": |
| 68 | return answer(intent, skillLibrary.detachSkill(slug, viewer, name, String(form.get("attachment") ?? ""))); |
| 69 | case "pin": |
| 70 | return answer(intent, skillLibrary.pinSkill(slug, viewer, name, String(form.get("attachment") ?? ""), null)); |
| 71 | case "add": |
| 72 | return answer(intent, workspaceAgents.installPlugin(slug, viewer, String(form.get("plugin") ?? ""))); |
| 73 | case "link": { |
| 74 | const repo = String(form.get("repo") ?? "").trim(); |
| 75 | if (!repo) return { ok: false, intent, error: "Name the repository as workspace/name." }; |
| 76 | return answer(intent, skillLibrary.setMirror(slug, viewer, repo)); |
| 77 | } |
| 78 | case "unlink": |
| 79 | return answer(intent, skillLibrary.setMirror(slug, viewer, null)); |
| 80 | case "sync": |
| 81 | return answer(intent, skillLibrary.syncMirror(slug, viewer)); |
| 82 | default: |
| 83 | return { ok: false, intent, error: "Unknown request." }; |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /** Where a skill reaches, as the control reads it. */ |
| 88 | function reachOf(skill: LibrarySkill): Reach[] { |
| 89 | return skill.attachments.map((a) => ({ id: a.id, scope: a.scope, target: a.target, label: a.label })); |
| 90 | } |
| 91 | |
| 92 | /** Who the viewer may give skills to: owners anyone, team maintainers their teams; null for everyone else. */ |
| 93 | function targetsOf(library: SkillLibrary) { |
| 94 | return library.can_manage || library.teams.length > 0 ? { can_manage: library.can_manage, teams: library.teams, agents: library.agents } : null; |
| 95 | } |
| 96 | |
| 97 | export default function CustomizeSkills({ loaderData }: Route.ComponentProps) { |
| 98 | const [params] = useSearchParams(); |
| 99 | return readView(params) === "discover" ? <Discover library={loaderData.library} listings={loaderData.discover ?? null} /> : <Yours library={loaderData.library} />; |
| 100 | } |
| 101 | |
| 102 | // ── Yours ──────────────────────────────────────────────────────────────── |
| 103 | |
| 104 | function Yours({ library }: { library: SkillLibrary | null }) { |
| 105 | const { slug } = useCustomize(); |
| 106 | const [params] = useSearchParams(); |
| 107 | const query = readQuery(params).toLowerCase(); |
| 108 | if (!library) return <NoAnswer what="The library" />; |
| 109 | const matching = (skill: LibrarySkill) => !query || [skill.name, skill.qualified_name, skill.description, skill.plugin ?? ""].some((text) => text.toLowerCase().includes(query)); |
| 110 | const drafts = library.skills.filter((s) => s.status === "draft" && matching(s)); |
| 111 | const published = library.skills.filter((s) => s.status === "published" && matching(s)); |
| 112 | const foundational = FOUNDATIONAL_SKILLS.filter((skill) => !query || skill.name.toLowerCase().includes(query) || skill.description.toLowerCase().includes(query)); |
| 113 | const targets = targetsOf(library); |
| 114 | return ( |
| 115 | <div className="space-y-10"> |
| 116 | {drafts.length > 0 && ( |
| 117 | <section aria-labelledby="drafts"> |
| 118 | <SectionHead id="drafts" title="Drafts to review" aside={`${drafts.length}`}> |
| 119 | Saved from finished sessions. No agent uses a draft until someone who writes skills reviews and publishes it. |
| 120 | </SectionHead> |
| 121 | <Card asChild divided className="overflow-hidden border-warn/30"> |
| 122 | <ul> |
| 123 | {drafts.map((skill) => ( |
| 124 | <SkillRow key={skill.id} slug={slug} skill={skill} targets={null} mirror={library.mirror} /> |
| 125 | ))} |
| 126 | </ul> |
| 127 | </Card> |
| 128 | </section> |
| 129 | )} |
| 130 | |
| 131 | <section aria-labelledby="library"> |
| 132 | <SectionHead id="library" title="Your workspace's skills" aside={`${published.length}`}> |
| 133 | How your agents do a kind of work, in the open SKILL.md format. A skill reaches the agents it is available to; they see its name and when to use it, and read the rest when a request matches. |
| 134 | </SectionHead> |
| 135 | {published.length === 0 ? ( |
| 136 | <EmptyState title={query ? "No skill matches" : "No skills yet"}> |
| 137 | {query ? ( |
| 138 | "Try another word, or look in Discover." |
| 139 | ) : library.can_write ? ( |
| 140 | <> |
| 141 | Write one, such as how you cut a release or your brand voice, import a SKILL.md or a zip, save a finished session as a skill, or add public skills from{" "} |
| 142 | <Link to={withParams(customizePath("skills", slug), params, { view: "discover" })} className="text-accent hover:underline"> |
| 143 | Discover |
| 144 | </Link> |
| 145 | . |
| 146 | </> |
| 147 | ) : ( |
| 148 | "Owners and team maintainers write skills. You can save a finished session as a skill for them to review, or ask for a public skill in Discover." |
| 149 | )} |
| 150 | </EmptyState> |
| 151 | ) : ( |
| 152 | <Card asChild divided className="overflow-hidden"> |
| 153 | <ul> |
| 154 | {published.map((skill) => ( |
| 155 | <SkillRow key={skill.id} slug={slug} skill={skill} targets={targets} mirror={library.mirror} /> |
| 156 | ))} |
| 157 | </ul> |
| 158 | </Card> |
| 159 | )} |
| 160 | </section> |
| 161 | |
| 162 | <section aria-labelledby="foundational"> |
| 163 | <SectionHead id="foundational" title="From g1t" aside={`Version ${FOUNDATIONAL_SKILLS_VERSION}, updated with every release`}> |
| 164 | Every agent has these. Owners turn one off for an agent on its Skills tab. |
| 165 | </SectionHead> |
| 166 | <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 167 | {foundational.map((skill) => ( |
| 168 | <li key={skill.id}> |
| 169 | <Card asChild className="flex h-full items-start gap-3 px-4 py-3 transition-colors hover:border-line-strong"> |
| 170 | <Link to={skillsPath(slug, skill.id)} prefetch="intent"> |
| 171 | <BookOpen size={15} className="mt-0.5 shrink-0 text-faint" aria-hidden /> |
| 172 | <span className="min-w-0"> |
| 173 | <span className="block font-mono text-[0.8125rem] font-medium text-fg">{skill.id}</span> |
| 174 | <span className="mt-0.5 block text-xs text-muted">{skill.description}</span> |
| 175 | </span> |
| 176 | </Link> |
| 177 | </Card> |
| 178 | </li> |
| 179 | ))} |
| 180 | </ul> |
| 181 | </section> |
| 182 | |
| 183 | <MirrorPanel slug={slug} mirror={library.mirror} canManage={library.can_manage} canWrite={library.can_write} /> |
| 184 | </div> |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | /** A library skill as a row: its name, what it is, where it came from, and the agents it is available to. */ |
| 189 | function SkillRow({ slug, skill, targets, mirror }: { slug: string; skill: LibrarySkill; targets: ReturnType<typeof targetsOf>; mirror: SkillMirror | null }) { |
| 190 | const fetcher = useFetcher<ActionResult>({ key: `skill-reach-${skill.id}` }); |
| 191 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 192 | const behind = skill.attachments.filter((a) => a.version < skill.version); |
| 193 | const draft = skill.status === "draft"; |
| 194 | const to = skillsPath(slug, skill.qualified_name, draft && skill.can_edit ? "/edit" : ""); |
| 195 | const change = (option: ReachOption, checked: boolean) => { |
| 196 | if (checked) fetcher.submit({ intent: "attach", name: skill.qualified_name, where: option.value }, { method: "post" }); |
| 197 | else if (option.reach) fetcher.submit({ intent: "detach", name: skill.qualified_name, attachment: option.reach.id }, { method: "post" }); |
| 198 | }; |
| 199 | return ( |
| 200 | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3.5 sm:flex-nowrap"> |
| 201 | <div className="min-w-0 grow"> |
| 202 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 203 | <Link to={to} prefetch="intent" className="font-mono text-sm font-medium text-fg hover:underline"> |
| 204 | {skill.qualified_name} |
| 205 | </Link> |
| 206 | {draft ? <Badge tone="warn">Draft</Badge> : <span className="text-xs text-faint">v{skill.version}</span>} |
| 207 | {skill.plugin && ( |
| 208 | <Link to={pluginPath(slug, skill.plugin)} className="inline-flex" prefetch="intent"> |
| 209 | <Badge tone="accent">from {skill.plugin}</Badge> |
| 210 | </Link> |
| 211 | )} |
| 212 | {skill.requires_computer && <NeedsComputer />} |
| 213 | {skill.mirrored && <FromRepository repo={mirror?.repo ?? null} />} |
| 214 | </div> |
| 215 | <p className="mt-0.5 line-clamp-2 text-sm text-muted">{skill.description}</p> |
| 216 | <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5"> |
| 217 | {draft ? <span className="text-xs text-faint">{originText(skill.origin)}</span> : <AvailableTo reach={reachOf(skill)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} />} |
| 218 | {behind.length > 0 && ( |
| 219 | <fetcher.Form method="post" className="inline-flex items-center gap-1.5"> |
| 220 | <input type="hidden" name="intent" value="pin" /> |
| 221 | <input type="hidden" name="name" value={skill.qualified_name} /> |
| 222 | <input type="hidden" name="attachment" value={behind[0]!.id} /> |
| 223 | <SubmitButton variant="link" size="inline" fetcher={fetcher} match={{ intent: "pin", attachment: behind[0]!.id }} pending="Updating…" className="text-xs text-warn"> |
| 224 | Update available on {behind.length === 1 ? behind[0]!.label : `${behind.length} attachments`} |
| 225 | </SubmitButton> |
| 226 | </fetcher.Form> |
| 227 | )} |
| 228 | </div> |
| 229 | {error && <p className="mt-1.5 text-sm text-danger">{error}</p>} |
| 230 | </div> |
| 231 | <div className="flex shrink-0 items-center gap-3 text-xs text-faint sm:flex-col sm:items-end"> |
| 232 | <p> |
| 233 | @{skill.updated_by} · <TimeAgo at={skill.updated_at} /> |
| 234 | </p> |
| 235 | <Link to={to} aria-label={`Open ${skill.qualified_name}`} className="hidden text-faint hover:text-fg sm:block"> |
| 236 | <ChevronRight size={16} aria-hidden /> |
| 237 | </Link> |
| 238 | </div> |
| 239 | </li> |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | /** The repository the library follows: `.claude/skills/<name>/` on its default branch, read after every push. */ |
| 244 | function MirrorPanel({ slug, mirror, canManage, canWrite }: { slug: string; mirror: SkillMirror | null; canManage: boolean; canWrite: boolean }) { |
| 245 | const link = useFetcher<ActionResult>({ key: "skills-link" }); |
| 246 | const sync = useFetcher<ActionResult>({ key: "skills-sync" }); |
| 247 | const linkError = link.state === "idle" && link.data && !link.data.ok ? link.data.error : null; |
| 248 | const syncError = sync.state === "idle" && sync.data && !sync.data.ok ? sync.data.error : null; |
| 249 | const problems = mirror?.error?.split("\n").filter(Boolean) ?? []; |
| 250 | return ( |
| 251 | <Card asChild> |
| 252 | <section aria-labelledby="repository"> |
| 253 | <div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3.5"> |
| 254 | <div className="flex min-w-0 items-start gap-3"> |
| 255 | <GitBranch size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden /> |
| 256 | <div className="min-w-0"> |
| 257 | <h2 id="repository" className="text-sm font-medium"> |
| 258 | Keep skills in a repository |
| 259 | </h2> |
| 260 | {mirror ? ( |
| 261 | <p className="mt-0.5 text-sm text-muted"> |
| 262 | Follows{" "} |
| 263 | <Link to={`/${mirror.repo}/tree/${mirror.branch}/.claude/skills`} className="font-mono text-fg hover:underline"> |
| 264 | {mirror.repo} |
| 265 | </Link> |
| 266 | : each folder in <code className="font-mono text-[0.8125rem]">.claude/skills/</code> on {mirror.branch} is a skill, and a push there publishes a new version. |
| 267 | {mirror.synced_at && ( |
| 268 | <span className="text-faint"> |
| 269 | {" "} |
| 270 | Read <TimeAgo at={mirror.synced_at} /> |
| 271 | {mirror.commit ? ` at ${mirror.commit.slice(0, 7)}` : ""}. |
| 272 | </span> |
| 273 | )} |
| 274 | </p> |
| 275 | ) : ( |
| 276 | <p className="mt-0.5 max-w-2xl text-sm text-muted"> |
| 277 | Optional. Link a repository and each <code className="font-mono text-[0.8125rem]">.claude/skills/<name>/</code> folder on its default branch becomes a skill here, updated by every push, so skills go |
| 278 | through the same pull requests and reviews as code. |
| 279 | </p> |
| 280 | )} |
| 281 | </div> |
| 282 | </div> |
| 283 | {mirror && ( |
| 284 | <div className="flex shrink-0 flex-wrap gap-2"> |
| 285 | {canWrite && ( |
| 286 | <sync.Form method="post"> |
| 287 | <input type="hidden" name="intent" value="sync" /> |
| 288 | <SubmitButton variant="outline" size="sm" fetcher={sync} match={{ intent: "sync" }} pending="Reading…"> |
| 289 | <RefreshCw size={13} /> |
| 290 | Read it again |
| 291 | </SubmitButton> |
| 292 | </sync.Form> |
| 293 | )} |
| 294 | {canManage && ( |
| 295 | <Confirm |
| 296 | title="Stop following the repository?" |
| 297 | confirm="Stop following" |
| 298 | fields={{ intent: "unlink" }} |
| 299 | fetcherKey="skills-unlink" |
| 300 | trigger={ |
| 301 | <Button type="button" variant="outline" size="sm" className="hover:border-danger/50 hover:text-danger"> |
| 302 | Stop following |
| 303 | </Button> |
| 304 | } |
| 305 | > |
| 306 | Its skills stay in the library as they are, and can be edited here again. Pushes to {mirror.repo} no longer change them. |
| 307 | </Confirm> |
| 308 | )} |
| 309 | </div> |
| 310 | )} |
| 311 | </div> |
| 312 | {(problems.length > 0 || syncError) && ( |
| 313 | <ul className="space-y-1 border-t border-line/60 px-4 py-3 text-sm text-warn" aria-label="What couldn't be read"> |
| 314 | {syncError && <li>{syncError}</li>} |
| 315 | {problems.map((p) => ( |
| 316 | <li key={p}>{p}</li> |
| 317 | ))} |
| 318 | </ul> |
| 319 | )} |
| 320 | {!mirror && canManage && ( |
| 321 | <link.Form method="post" className="flex flex-wrap items-start gap-2 border-t border-line/60 px-4 py-3"> |
| 322 | <input type="hidden" name="intent" value="link" /> |
| 323 | <label htmlFor="skills-repo" className="sr-only"> |
| 324 | Repository |
| 325 | </label> |
| 326 | <Input id="skills-repo" name="repo" placeholder={`${slug}/agents`} autoComplete="off" spellCheck={false} className="max-w-xs grow font-mono" /> |
| 327 | <SubmitButton variant="outline" fetcher={link} match={{ intent: "link" }} pending="Linking…"> |
| 328 | Link repository |
| 329 | </SubmitButton> |
| 330 | {linkError && <p className="w-full text-sm text-danger">{linkError}</p>} |
| 331 | </link.Form> |
| 332 | )} |
| 333 | </section> |
| 334 | </Card> |
| 335 | ); |
| 336 | } |
| 337 | |
| 338 | // ── Discover ───────────────────────────────────────────────────────────── |
| 339 | |
| 340 | function Discover({ library, listings }: { library: SkillLibrary | null; listings: SkillListing[] | null }) { |
| 341 | const { slug, owner, username, requests } = useCustomize(); |
| 342 | const [params, setParams] = useSearchParams(); |
| 343 | if (!listings) return <NoAnswer what="Public skills" />; |
| 344 | const category = params.get("category") || null; |
| 345 | const shown = filterSkills(listings, { q: readQuery(params), category }); |
| 346 | const categories = categoriesOf(listings); |
| 347 | const marketplaces = new Set(listings.map((listing) => listing.marketplace)).size; |
| 348 | const grouped = groupByMarketplace(shown); |
| 349 | const targets = library ? targetsOf(library) : null; |
| 350 | return ( |
| 351 | <div className="space-y-8"> |
| 352 | <FeaturedBanner |
| 353 | title="Public skills, as they are" |
| 354 | action={ |
| 355 | <ButtonLink to={customizePath("marketplaces", slug)} variant="outline" size="sm"> |
| 356 | {marketplaces} {marketplaces === 1 ? "marketplace" : "marketplaces"} |
| 357 | </ButtonLink> |
| 358 | } |
| 359 | > |
| 360 | Every skill published in the marketplaces this workspace reads, in the open SKILL.md format. Adding one installs the plugin that brings it; then you say which agents it is available to. |
| 361 | </FeaturedBanner> |
| 362 | <div className="flex flex-wrap items-center gap-2"> |
| 363 | {categories.length > 0 && ( |
| 364 | <FilterMenu label="Category" all="All" value={category} options={categories.map((c) => ({ value: c, label: c }))} onChange={(next) => setParams(nextParams(params, { category: next }), { replace: true, preventScrollReset: true })} /> |
| 365 | )} |
| 366 | <span className="text-xs text-faint"> |
| 367 | {shown.length} of {listings.length} |
| 368 | </span> |
| 369 | </div> |
| 370 | {shown.length === 0 ? ( |
| 371 | <EmptyState title="No skill matches">Try another word or category, or add a marketplace that lists it.</EmptyState> |
| 372 | ) : ( |
| 373 | grouped.map((group) => ( |
| 374 | <section key={group.marketplace} aria-labelledby={`from-${group.marketplace}`}> |
| 375 | <SectionHead id={`from-${group.marketplace}`} title={`From ${group.marketplace}`} aside={`${group.items.length}`} /> |
| 376 | <ListingGrid> |
| 377 | {group.items.map((listing) => ( |
| 378 | <SkillCard key={listing.ref} listing={listing} slug={slug} owner={owner} requested={requestedBy(requests?.requests ?? [], skillListingRef(listing), username)} waiting={owner ? waitingFor(requests?.requests ?? [], skillListingRef(listing)) : 0} library={library} targets={targets} /> |
| 379 | ))} |
| 380 | </ListingGrid> |
| 381 | </section> |
| 382 | )) |
| 383 | )} |
| 384 | </div> |
| 385 | ); |
| 386 | } |
| 387 | |
| 388 | /** A skill in Discover: what it is, the plugin that brings it, and Add, Request, or where it is available once added. */ |
| 389 | function SkillCard({ |
| 390 | listing, |
| 391 | slug, |
| 392 | owner, |
| 393 | requested, |
| 394 | waiting, |
| 395 | library, |
| 396 | targets, |
| 397 | }: { |
| 398 | listing: SkillListing; |
| 399 | slug: string; |
| 400 | owner: boolean; |
| 401 | requested: boolean; |
| 402 | waiting: number; |
| 403 | library: SkillLibrary | null; |
| 404 | targets: ReturnType<typeof targetsOf>; |
| 405 | }) { |
| 406 | const fetcher = useFetcher<ActionResult>({ key: `discover-skill-${listing.ref}` }); |
| 407 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 408 | const pluginRef = `${listing.plugin}@${listing.marketplace}`; |
| 409 | const added = library?.skills.find((skill) => skill.plugin === pluginRef && skill.name.toLowerCase() === listing.name.toLowerCase()) ?? null; |
| 410 | const available = listing.availability.kind === "available" || listing.availability.kind === "installed"; |
| 411 | const change = (option: ReachOption, checked: boolean) => { |
| 412 | if (!added) return; |
| 413 | if (checked) fetcher.submit({ intent: "attach", name: added.qualified_name, where: option.value }, { method: "post" }); |
| 414 | else if (option.reach) fetcher.submit({ intent: "detach", name: added.qualified_name, attachment: option.reach.id }, { method: "post" }); |
| 415 | }; |
| 416 | const action = added ? ( |
| 417 | <AvailableTo reach={reachOf(added)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} /> |
| 418 | ) : !available ? null : owner ? ( |
| 419 | <fetcher.Form method="post"> |
| 420 | <input type="hidden" name="intent" value="add" /> |
| 421 | <input type="hidden" name="plugin" value={pluginRef} /> |
| 422 | <SubmitButton size="xs" fetcher={fetcher} match={{ intent: "add", plugin: pluginRef }} pending="Adding…"> |
| 423 | <Plus size={13} /> |
| 424 | Add |
| 425 | </SubmitButton> |
| 426 | </fetcher.Form> |
| 427 | ) : ( |
| 428 | <RequestButton slug={slug} listing={skillListingRef(listing)} name={listing.name} requested={requested} size="xs" /> |
| 429 | ); |
| 430 | return ( |
| 431 | <ListingCard |
| 432 | mark={<ListingMark id={listing.ref} name={listing.name} />} |
| 433 | title={<span className="font-mono">{listing.name}</span>} |
| 434 | to={pluginPath(slug, pluginRef)} |
| 435 | description={listing.description} |
| 436 | badge={added || listing.added ? <Badge tone="success">Added</Badge> : !available ? <AvailabilityBadge availability={listing.availability} /> : null} |
| 437 | muted={!available} |
| 438 | meta={ |
| 439 | <> |
| 440 | From {listing.plugin} |
| 441 | {listing.author ? ` · by ${listing.author}` : ""} |
| 442 | {waiting > 0 && ( |
| 443 | <> |
| 444 | {" · "} |
| 445 | <Waiting slug={slug} count={waiting} /> |
| 446 | </> |
| 447 | )} |
| 448 | </> |
| 449 | } |
| 450 | action={action} |
| 451 | > |
| 452 | {error && <p className="text-xs text-danger">{error}</p>} |
| 453 | </ListingCard> |
| 454 | ); |
| 455 | } |