Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 1 | /** |
| 2 | * One plugin's page, read from its source as Claude Code would read it: its | |
| 3 | * README, what it brings g1t's agents (skills, each given to agents from | |
| 4 | * here once installed; subagents, each hireable as a starting point; | |
| 5 | * remote MCP servers, connectors once installed) and what only Claude Code | |
| 6 | * on a runner uses; and for owners Install, Update, the switch, and | |
| 7 | * Uninstall. Members ask with Request. | |
| 8 | */ | |
| 9 | import { ArrowLeft, ArrowUpCircle, Bot, ExternalLink, Plus, Power, Trash2, UserPlus } from "lucide-react"; | |
| 10 | import { Link, data, useFetcher } from "react-router"; | |
| 11 | ||
| 12 | import type { PluginDetail, SkillLibrary, WorkspaceConnector } from "@g1t/contracts"; | |
| 13 | import { customizePath } from "@g1t/contracts/customize"; | |
| 14 | ||
| 15 | import type { Route } from "./+types/plugin"; | |
| 16 | import { useCustomize } from "./layout"; | |
| 17 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; | |
| 18 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; | |
| 19 | import { skillsPath } from "../../../components/agents/skills"; | |
| 20 | import { AvailabilityBadge, AvailableTo, Chips, ListingMark, NoAnswer, RequestButton, SectionHead, StateBadge } from "../../../components/customize"; | |
| 21 | import { Markdown } from "../../../components/markdown"; | |
| 22 | import { ButtonLink, SubmitButton, TimeAgo } from "../../../components/ui"; | |
| 23 | import { Alert } from "../../../components/ui/alert"; | |
| 24 | import { Badge } from "../../../components/ui/badge"; | |
| 25 | import { Button } from "../../../components/ui/button"; | |
| 26 | import { Card } from "../../../components/ui/card"; | |
| 27 | import { type Reach, type ReachOption, type ReachTargets, claudeCodeOnly, connectorPagePath, fetchWords, pluginListingRef, requestedBy, splitTarget } from "../../../lib/customize"; | |
| 28 | import { addConnectorThen } from "../../../lib/customize.server"; | |
| 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 function meta({ params, ...args }: Route.MetaArgs) { | |
| 34 | const listing = args.loaderData?.detail?.listing; | |
| 35 | return page(args, { title: `${listing?.display_name ?? listing?.name ?? "Plugin"} · Customize · ${params.owner} · g1t` }); | |
| 36 | } | |
| 37 | ||
| 38 | type Loaded = { detail: PluginDetail | null; library: SkillLibrary | null; connectors: WorkspaceConnector[] }; | |
| 39 | ||
| 40 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<Loaded> { | |
| 41 | const viewer = requireUser(context, request); | |
| 42 | const slug = params.owner.toLowerCase(); | |
| 43 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 44 | const [found, library, connectors] = await Promise.all([ | |
| 45 | workspaceAgents.plugin(slug, viewer, params.ref).catch(() => null), | |
| 46 | readOrNull(skillLibrary.library(slug, viewer)), | |
| 47 | readOrNull(workspaceAgents.connectors(slug, viewer)), | |
| 48 | ]); | |
| 49 | if (found && !found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 502 }); | |
| 50 | return { detail: found?.value ?? null, library, connectors: connectors ?? [] }; | |
| 51 | } | |
| 52 | ||
| 53 | /** Install, update, switch or uninstall the plugin; connect one of its servers; give one of its skills to agents. */ | |
| 54 | export async function action({ params, context, request }: Route.ActionArgs): Promise<Response | ActionResult> { | |
| 55 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); | |
| 56 | const intent = String(form.get("intent") ?? ""); | |
| 57 | const ref = params.ref; | |
| 58 | switch (intent) { | |
| 59 | case "install": | |
| 60 | return answer(intent, workspaceAgents.installPlugin(slug, viewer, ref)); | |
| 61 | case "update": | |
| 62 | return answer(intent, workspaceAgents.updatePlugin(slug, viewer, ref)); | |
| 63 | case "switch": | |
| 64 | return answer(intent, workspaceAgents.setPluginEnabled(slug, viewer, ref, form.get("enabled") === "on")); | |
| 65 | case "uninstall": | |
| 66 | return answer(intent, workspaceAgents.uninstallPlugin(slug, viewer, ref)); | |
| 67 | case "connect": | |
| 68 | return addConnectorThen(slug, viewer, { from: { kind: "plugin", ref, server: String(form.get("server") ?? "") }, auth: form.get("auth") === "header" ? "header" : form.get("auth") === "none" ? "none" : "oauth", header: null }); | |
| 69 | case "attach": { | |
| 70 | const { scope, target } = splitTarget(String(form.get("where") ?? "")); | |
| 71 | return answer(intent, skillLibrary.attachSkill(slug, viewer, String(form.get("name") ?? ""), scope, target)); | |
| 72 | } | |
| 73 | case "detach": | |
| 74 | return answer(intent, skillLibrary.detachSkill(slug, viewer, String(form.get("name") ?? ""), String(form.get("attachment") ?? ""))); | |
| 75 | default: | |
| 76 | return { ok: false, intent, error: "Unknown request." }; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | export default function PluginPage({ loaderData, params }: Route.ComponentProps) { | |
| 81 | const { slug, owner, username, requests } = useCustomize(); | |
| 82 | const { detail, library, connectors } = loaderData; | |
| 83 | const back = ( | |
| 84 | <Link to={`${customizePath("plugins", slug)}?view=discover`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 85 | <ArrowLeft size={14} /> | |
| 86 | Plugins | |
| 87 | </Link> | |
| 88 | ); | |
| 89 | if (!detail) { | |
| 90 | return ( | |
| 91 | <div className="space-y-6"> | |
| 92 | {back} | |
| 93 | <NoAnswer what={params.ref} /> | |
| 94 | </div> | |
| 95 | ); | |
| 96 | } | |
| 97 | const { listing, install } = detail; | |
| 98 | const components = install?.components ?? detail.components; | |
| 99 | const name = listing.display_name ?? listing.name; | |
| 100 | const enabled = install?.enabled ?? false; | |
| 101 | const targets: ReachTargets | null = library && (library.can_manage || library.teams.length > 0) ? { can_manage: library.can_manage, teams: library.teams, agents: library.agents } : null; | |
| 102 | const runner = components ? claudeCodeOnly(components) : []; | |
| 103 | const problems = [...detail.problems, ...(install?.problems ?? [])]; | |
| 104 | return ( | |
| 105 | <div className="space-y-8"> | |
| 106 | {back} | |
| 107 | <header className="flex flex-wrap items-start gap-4"> | |
| 108 | <ListingMark id={listing.ref} name={name} size={56} /> | |
| 109 | <div className="min-w-0 grow basis-60"> | |
| 110 | <div className="flex flex-wrap items-center gap-2"> | |
| 111 | <h2 className="text-xl font-semibold tracking-tight">{name}</h2> | |
| 112 | <AvailabilityBadge availability={listing.availability} /> | |
| 113 | {install?.update && <Badge tone="warn">Update available{install.update.version ? `: ${install.update.version}` : ""}</Badge>} | |
| 114 | </div> | |
| 115 | <p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted"> | |
| 116 | {listing.author && <span>by {listing.author}</span>} | |
| 117 | {listing.author && ( | |
| 118 | <span aria-hidden="true" className="text-faint"> | |
| 119 | · | |
| 120 | </span> | |
| 121 | )} | |
| 122 | <Link to={`${customizePath("plugins", slug)}?view=discover&marketplace=${encodeURIComponent(listing.marketplace)}`} className="hover:text-fg hover:underline"> | |
| 123 | {listing.marketplace} | |
| 124 | </Link> | |
| 125 | {listing.version && ( | |
| 126 | <> | |
| 127 | <span aria-hidden="true" className="text-faint"> | |
| 128 | · | |
| 129 | </span> | |
| 130 | <span>v{listing.version}</span> | |
| 131 | </> | |
| 132 | )} | |
| 133 | {listing.category && ( | |
| 134 | <> | |
| 135 | <span aria-hidden="true" className="text-faint"> | |
| 136 | · | |
| 137 | </span> | |
| 138 | <span>{listing.category}</span> | |
| 139 | </> | |
| 140 | )} | |
| 141 | </p> | |
| 142 | <p className="mt-2 max-w-3xl text-sm text-fg-soft">{listing.description}</p> | |
| 143 | </div> | |
| 144 | <Actions detail={detail} slug={slug} owner={owner} requested={requestedBy(requests?.requests ?? [], pluginListingRef(listing), username)} /> | |
| 145 | </header> | |
| 146 | {install && !enabled && ( | |
| 147 | <Alert tone="warn" asChild> | |
| 148 | <p>Switched off: its skills, agents and connectors are kept but reach no agent until an owner switches it on.</p> | |
| 149 | </Alert> | |
| 150 | )} | |
| 151 | {problems.length > 0 && ( | |
| 152 | <Alert tone="warn" asChild> | |
| 153 | <div> | |
| 154 | <p className="font-medium">What couldn't be read</p> | |
| 155 | <ul className="mt-1 list-disc space-y-0.5 pl-5 text-muted"> | |
| 156 | {problems.map((problem) => ( | |
| 157 | <li key={problem}>{problem}</li> | |
| 158 | ))} | |
| 159 | </ul> | |
| 160 | </div> | |
| 161 | </Alert> | |
| 162 | )} | |
| 163 | ||
| 164 | <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 165 | <div className="min-w-0 space-y-8"> | |
| 166 | {components && ( | |
| 167 | <section aria-labelledby="components" className="space-y-6"> | |
| 168 | <SectionHead id="components" title="What it brings"> | |
| 169 | Skills, agents and connectors reach g1t's agents once it is installed; the rest reaches Claude Code when it runs on a g1t runner. | |
| 170 | </SectionHead> | |
| 171 | <Group title="Skills" count={components.skills.length + components.commands.length} empty="None."> | |
| 172 | {[...components.skills, ...components.commands.map((command) => ({ name: command.name, description: "A command: the older one-file form of a skill.", path: command.path }))].map((skill) => { | |
| 173 | const added = library?.skills.find((s) => s.plugin === listing.ref && s.name.toLowerCase() === skill.name.toLowerCase()) ?? null; | |
| 174 | return ( | |
| 175 | <li key={skill.path} className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 176 | <div className="min-w-0 grow"> | |
| 177 | {added ? ( | |
| 178 | <Link to={skillsPath(slug, added.qualified_name)} className="font-mono text-sm font-medium hover:underline"> | |
| 179 | {added.qualified_name} | |
| 180 | </Link> | |
| 181 | ) : ( | |
| 182 | <span className="font-mono text-sm font-medium"> | |
| 183 | {listing.name}:{skill.name} | |
| 184 | </span> | |
| 185 | )} | |
| 186 | {skill.description && <p className="mt-0.5 text-sm text-muted">{skill.description}</p>} | |
| 187 | </div> | |
| 188 | {added && <SkillReach skill={added} targets={targets} />} | |
| 189 | </li> | |
| 190 | ); | |
| 191 | })} | |
| 192 | </Group> | |
| 193 | <Group title="Agents" count={components.agents.length} empty="None."> | |
| 194 | {components.agents.map((agent) => { | |
| 195 | const file = detail.agents.find((a) => a.name === agent.name); | |
| 196 | return ( | |
| 197 | <li key={agent.path} className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 198 | <Bot size={16} className="mt-0.5 shrink-0 text-faint" aria-hidden /> | |
| 199 | <div className="min-w-0 grow"> | |
| 200 | <p className="flex flex-wrap items-center gap-2"> | |
| 201 | <span className="font-mono text-sm font-medium">{agent.name}</span> | |
| 202 | {file?.model && <Badge>{file.model}</Badge>} | |
| 203 | </p> | |
| 204 | {(agent.description || file?.description) && <p className="mt-0.5 text-sm text-muted">{agent.description ?? file?.description}</p>} | |
| 205 | </div> | |
| 206 | {install && enabled && owner ? ( | |
| 207 | <ButtonLink to={`/${slug}/-/agents/new?plugin=${encodeURIComponent(listing.ref)}&agent=${encodeURIComponent(agent.name)}`} variant="outline" size="xs"> | |
| 208 | <UserPlus size={12} /> | |
| 209 | Hire | |
| 210 | </ButtonLink> | |
| 211 | ) : ( | |
| 212 | <span className="text-xs text-faint">{install ? (enabled ? "Owners hire agents" : "Switched off") : "Hire it once installed"}</span> | |
| 213 | )} | |
| 214 | </li> | |
| 215 | ); | |
| 216 | })} | |
| 217 | </Group> | |
| 218 | <Group title="Connectors" count={components.mcp_servers.length} empty="None."> | |
| 219 | {components.mcp_servers.map((server) => { | |
| 220 | const connector = connectors.find((c) => c.from.kind === "plugin" && c.from.ref === listing.ref && c.from.server === server.name) ?? null; | |
| 221 | return ( | |
| 222 | <li key={server.name} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3"> | |
| 223 | <div className="min-w-0 grow"> | |
| 224 | <p className="flex flex-wrap items-center gap-2"> | |
| 225 | {connector ? ( | |
| 226 | <Link to={connectorPagePath(slug, connector.id)} className="font-mono text-sm font-medium hover:underline"> | |
| 227 | {server.name} | |
| 228 | </Link> | |
| 229 | ) : ( | |
| 230 | <span className="font-mono text-sm font-medium">{server.name}</span> | |
| 231 | )} | |
| 232 | {!server.remote ? <Badge>Claude Code only</Badge> : connector ? <StateBadge state={connector.state} problem={connector.problem} /> : null} | |
| 233 | {connector && install && !enabled && <span className="text-xs text-faint">Stopped while the plugin is off</span>} | |
| 234 | </p> | |
| 235 | <p className="mt-0.5 truncate font-mono text-xs text-muted">{server.url ?? `${server.type} server, run on the machine Claude Code runs on`}</p> | |
| 236 | </div> | |
| 237 | {server.remote && install && !connector && owner && <ConnectServer server={server.name} />} | |
| 238 | </li> | |
| 239 | ); | |
| 240 | })} | |
| 241 | </Group> | |
| 242 | {runner.length > 0 && ( | |
| 243 | <div> | |
| 244 | <h3 className="text-sm font-semibold">Claude Code only</h3> | |
| 245 | <p className="mt-0.5 mb-2 text-sm text-muted">Read by Claude Code on a g1t runner, with the plugin fetched at its pinned commit; g1t's own agents don't use these.</p> | |
| 246 | <Chips items={runner} /> | |
| 247 | </div> | |
| 248 | )} | |
| 249 | </section> | |
| 250 | )} | |
| 251 | <section aria-labelledby="readme"> | |
| 252 | <SectionHead id="readme" title="README" /> | |
| 253 | {detail.readme ? ( | |
| 254 | <Card className="p-5 sm:p-6"> | |
| 255 | <Markdown source={detail.readme} /> | |
| 256 | </Card> | |
| 257 | ) : ( | |
| 258 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted"> | |
| 259 | <p>{components ? "It has no README." : "Its files couldn't be read, so there is nothing more to show than its listing."}</p> | |
| 260 | </Card> | |
| 261 | )} | |
| 262 | </section> | |
| 263 | </div> | |
| 264 | ||
| 265 | <aside className="text-sm"> | |
| 266 | <Card asChild className="space-y-3 p-4"> | |
| 267 | <dl> | |
| 268 | <div> | |
| 269 | <dt className="text-xs text-faint">Source</dt> | |
| 270 | <dd className="mt-0.5 break-words">{fetchWords(install?.fetch ?? listing.fetch)}</dd> | |
| 271 | </div> | |
| 272 | {(install?.sha ?? detail.manifest?.version) && ( | |
| 273 | <div> | |
| 274 | <dt className="text-xs text-faint">{install ? "Installed at" : "Version"}</dt> | |
| 275 | <dd className="mt-0.5 font-mono text-xs">{install ? install.sha?.slice(0, 7) : detail.manifest?.version}</dd> | |
| 276 | </div> | |
| 277 | )} | |
| 278 | {install && ( | |
| 279 | <div> | |
| 280 | <dt className="text-xs text-faint">Installed</dt> | |
| 281 | <dd className="mt-0.5"> | |
| 282 | By @{install.installed_by} · <TimeAgo at={install.installed_at} /> | |
| 283 | </dd> | |
| 284 | </div> | |
| 285 | )} | |
| 286 | {detail.manifest?.license && ( | |
| 287 | <div> | |
| 288 | <dt className="text-xs text-faint">License</dt> | |
| 289 | <dd className="mt-0.5">{detail.manifest.license}</dd> | |
| 290 | </div> | |
| 291 | )} | |
| 292 | {(listing.homepage ?? detail.manifest?.repository) && ( | |
| 293 | <div> | |
| 294 | <dt className="text-xs text-faint">More</dt> | |
| 295 | <dd className="mt-0.5"> | |
| 296 | <a href={listing.homepage ?? detail.manifest!.repository!} target="_blank" rel="noreferrer" className="flex items-center gap-1 hover:underline"> | |
| 297 | <span className="truncate">{listing.homepage ?? detail.manifest!.repository}</span> | |
| 298 | <ExternalLink size={11} className="shrink-0" aria-hidden /> | |
| 299 | </a> | |
| 300 | </dd> | |
| 301 | </div> | |
| 302 | )} | |
| 303 | {listing.tags.length > 0 && ( | |
| 304 | <div> | |
| 305 | <dt className="text-xs text-faint">Tags</dt> | |
| 306 | <dd className="mt-1"> | |
| 307 | <Chips items={listing.tags} /> | |
| 308 | </dd> | |
| 309 | </div> | |
| 310 | )} | |
| 311 | </dl> | |
| 312 | </Card> | |
| 313 | </aside> | |
| 314 | </div> | |
| 315 | </div> | |
| 316 | ); | |
| 317 | } | |
| 318 | ||
| 319 | /** A list of what a plugin brings, under its heading, or a word when there is nothing. */ | |
| 320 | function Group({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) { | |
| 321 | return ( | |
| 322 | <div> | |
| 323 | <h3 className="mb-2 flex items-baseline gap-2 text-sm font-semibold"> | |
| 324 | {title} | |
| 325 | <span className="text-xs font-normal text-faint tabular-nums">{count}</span> | |
| 326 | </h3> | |
| 327 | {count === 0 ? ( | |
| 328 | <Card asChild tone="plain" className="border-dashed px-4 py-3 text-sm text-muted"> | |
| 329 | <p>{empty}</p> | |
| 330 | </Card> | |
| 331 | ) : ( | |
| 332 | <Card asChild divided className="overflow-hidden"> | |
| 333 | <ul>{children}</ul> | |
| 334 | </Card> | |
| 335 | )} | |
| 336 | </div> | |
| 337 | ); | |
| 338 | } | |
| 339 | ||
| 340 | /** Which agents one of the plugin's skills reaches, with the control that changes it. */ | |
| 341 | function SkillReach({ skill, targets }: { skill: SkillLibrary["skills"][number]; targets: ReachTargets | null }) { | |
| 342 | const fetcher = useFetcher<ActionResult>({ key: `plugin-skill-${skill.id}` }); | |
| 343 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 344 | const reach: Reach[] = skill.attachments.map((a) => ({ id: a.id, scope: a.scope, target: a.target, label: a.label })); | |
| 345 | const change = (option: ReachOption, checked: boolean) => { | |
| 346 | if (checked) fetcher.submit({ intent: "attach", name: skill.qualified_name, where: option.value }, { method: "post" }); | |
| 347 | else if (option.reach) fetcher.submit({ intent: "detach", name: skill.qualified_name, attachment: option.reach.id }, { method: "post" }); | |
| 348 | }; | |
| 349 | return ( | |
| 350 | <div className="flex shrink-0 flex-col items-end gap-1"> | |
| 351 | <AvailableTo reach={reach} targets={targets} busy={fetcher.state !== "idle"} onChange={change} /> | |
| 352 | {error && <p className="text-xs text-danger">{error}</p>} | |
| 353 | </div> | |
| 354 | ); | |
| 355 | } | |
| 356 | ||
| 357 | /** Connect one of the plugin's remote servers that has no connector yet. */ | |
| 358 | function ConnectServer({ server }: { server: string }) { | |
| 359 | const fetcher = useFetcher<ActionResult>({ key: `plugin-connect-${server}` }); | |
| 360 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 361 | return ( | |
| 362 | <fetcher.Form method="post" className="flex flex-col items-end gap-1"> | |
| 363 | <input type="hidden" name="intent" value="connect" /> | |
| 364 | <input type="hidden" name="server" value={server} /> | |
| 365 | <SubmitButton variant="outline" size="xs" fetcher={fetcher} match={{ intent: "connect", server }} pending="Connecting…"> | |
| 366 | <Plus size={12} /> | |
| 367 | Connect | |
| 368 | </SubmitButton> | |
| 369 | {error && <p className="text-xs text-danger">{error}</p>} | |
| 370 | </fetcher.Form> | |
| 371 | ); | |
| 372 | } | |
| 373 | ||
| 374 | /** What the viewer can do with the plugin: owners install, update, switch and uninstall; members ask. */ | |
| 375 | function Actions({ detail, slug, owner, requested }: { detail: PluginDetail; slug: string; owner: boolean; requested: boolean }) { | |
| 376 | const { listing, install } = detail; | |
| 377 | const fetcher = useFetcher<ActionResult>({ key: `plugin-${listing.ref}` }); | |
| 378 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 379 | const name = listing.display_name ?? listing.name; | |
| 380 | if (listing.availability.kind === "soon" || listing.availability.kind === "unavailable") return null; | |
| 381 | if (!owner) { | |
| 382 | return install ? null : <RequestButton slug={slug} listing={pluginListingRef(listing)} name={name} requested={requested} />; | |
| 383 | } | |
| 384 | return ( | |
| 385 | <div className="flex shrink-0 flex-col items-end gap-2"> | |
| 386 | <div className="flex flex-wrap items-center gap-2"> | |
| 387 | {!install ? ( | |
| 388 | <fetcher.Form method="post"> | |
| 389 | <input type="hidden" name="intent" value="install" /> | |
| 390 | <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "install" }} pending="Installing…"> | |
| 391 | <Plus size={14} /> | |
| 392 | Install | |
| 393 | </SubmitButton> | |
| 394 | </fetcher.Form> | |
| 395 | ) : ( | |
| 396 | <> | |
| 397 | {install.update && ( | |
| 398 | <fetcher.Form method="post"> | |
| 399 | <input type="hidden" name="intent" value="update" /> | |
| 400 | <SubmitButton variant="accent" size="sm" fetcher={fetcher} match={{ intent: "update" }} pending="Updating…"> | |
| 401 | <ArrowUpCircle size={14} /> | |
| 402 | Update{install.update.version ? ` to ${install.update.version}` : ""} | |
| 403 | </SubmitButton> | |
| 404 | </fetcher.Form> | |
| 405 | )} | |
| 406 | <fetcher.Form method="post"> | |
| 407 | <input type="hidden" name="intent" value="switch" /> | |
| 408 | <input type="hidden" name="enabled" value={install.enabled ? "off" : "on"} /> | |
| 409 | <SubmitButton variant="outline" size="sm" fetcher={fetcher} match={{ intent: "switch" }} pending="Saving…"> | |
| 410 | <Power size={14} /> | |
| 411 | {install.enabled ? "Switch off" : "Switch on"} | |
| 412 | </SubmitButton> | |
| 413 | </fetcher.Form> | |
| 414 | <Confirm | |
| 415 | title={`Uninstall ${name}?`} | |
| 416 | confirm="Uninstall" | |
| 417 | fields={{ intent: "uninstall" }} | |
| 418 | fetcherKey={`uninstall-${listing.ref}`} | |
| 419 | trigger={ | |
| 420 | <Button type="button" variant="outline" size="sm" className="hover:border-danger/50 hover:text-danger"> | |
| 421 | <Trash2 size={14} /> | |
| 422 | Uninstall | |
| 423 | </Button> | |
| 424 | } | |
| 425 | > | |
| 426 | Its skills leave the library, its agents leave Templates and its connectors are removed, at once. Agents hired from it stay as they are. | |
| 427 | </Confirm> | |
| 428 | </> | |
| 429 | )} | |
| 430 | </div> | |
| 431 | {error && <p className="text-xs text-danger">{error}</p>} | |
| 432 | </div> | |
| 433 | ); | |
| 434 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.