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 | * Customize's pieces (routes/workspace/customize/): the card every listing | |
| 3 | * shares, the badges that say whether something can be added or is | |
| 4 | * connected, the Available-to control that says which agents something | |
| 5 | * reaches, the Request button members ask owners with, a request as owners | |
| 6 | * and askers see it, filter menus, and section headings. | |
| 7 | */ | |
| 8 | import { Check, ChevronDown, Clock, Info, Plus, Send, Users, X } from "lucide-react"; | |
| 9 | import { type ReactNode, useEffect, useId, useState } from "react"; | |
| 10 | import { Link, useFetcher } from "react-router"; | |
| 11 | ||
| 12 | import type { ConnectorState, InstallRequest, PluginAvailability } from "@g1t/contracts"; | |
| 13 | import { customizePath } from "@g1t/contracts/customize"; | |
| 14 | ||
| 15 | import type { useDialogFetcher } from "./agents/dialogs"; | |
| 16 | import { ConnectorMark } from "./connectors"; | |
| 17 | import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 18 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 19 | import { Button, buttonVariants } from "./ui/button"; | |
| 20 | import { Card } from "./ui/card"; | |
| 21 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 22 | import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 23 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 24 | import { Hint } from "./ui/hint"; | |
| 25 | import { Input } from "./ui/input"; | |
| 26 | import { Textarea } from "./ui/textarea"; | |
| 27 | import { cn } from "../lib/cn"; | |
| 28 | import { CONNECTOR_STATES, type Reach, type ReachOption, type ReachTargets, availabilityWord, kindWord, reachOptions, reachWords } from "../lib/customize"; | |
| 29 | ||
| 30 | /** A listing's mark: its letters on a steady colour, from its id and name. */ | |
| 31 | export function ListingMark({ id, name, size = 40 }: { id: string; name: string; size?: number }) { | |
| 32 | return <ConnectorMark view={{ id, name, provider: null }} size={size} />; | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * The card every listing shares: its mark, its name (a link over the whole | |
| 37 | * card when it has a page), one line about it, what sits under it | |
| 38 | * (`children`), and a last line with where it is from (`meta`) and what | |
| 39 | * can be done with it (`action`). `muted` draws one nobody can add yet. | |
| 40 | */ | |
| 41 | export function ListingCard({ | |
| 42 | mark, | |
| 43 | title, | |
| 44 | to, | |
| 45 | description, | |
| 46 | badge, | |
| 47 | meta, | |
| 48 | action, | |
| 49 | muted = false, | |
| 50 | children, | |
| 51 | }: { | |
| 52 | mark: ReactNode; | |
| 53 | title: ReactNode; | |
| 54 | to?: string; | |
| 55 | description: ReactNode; | |
| 56 | badge?: ReactNode; | |
| 57 | meta?: ReactNode; | |
| 58 | action?: ReactNode; | |
| 59 | muted?: boolean; | |
| 60 | children?: ReactNode; | |
| 61 | }) { | |
| 62 | return ( | |
| 63 | <Card asChild className={cn("relative flex flex-col gap-3 p-4 transition-colors", muted ? "border-dashed bg-transparent" : "hover:border-line-strong")}> | |
| 64 | <article> | |
| 65 | <div className="flex items-start gap-3"> | |
| 66 | <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>{mark}</span> | |
| 67 | <div className="min-w-0 grow"> | |
| 68 | <div className="flex items-start justify-between gap-2"> | |
| 69 | <h3 className={cn("min-w-0 truncate text-sm font-semibold", muted && "text-fg-soft")}> | |
| 70 | {to ? ( | |
| 71 | <Link to={to} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent"> | |
| 72 | {title} | |
| 73 | </Link> | |
| 74 | ) : ( | |
| 75 | title | |
| 76 | )} | |
| 77 | </h3> | |
| 78 | {badge && <span className="relative z-10 flex shrink-0 items-center gap-1.5">{badge}</span>} | |
| 79 | </div> | |
| 80 | <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{description}</p> | |
| 81 | </div> | |
| 82 | </div> | |
| 83 | {children && <div className="relative z-10">{children}</div>} | |
| 84 | <div className="relative z-10 mt-auto flex min-h-8 items-center justify-between gap-3"> | |
| 85 | <p className="min-w-0 truncate text-xs text-muted">{meta}</p> | |
| 86 | {action && <span className="flex shrink-0 items-center gap-2">{action}</span>} | |
| 87 | </div> | |
| 88 | </article> | |
| 89 | </Card> | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | /** Small chips under a card: what a plugin brings, a connector's tools. */ | |
| 94 | export function Chips({ items, muted = false, label }: { items: readonly string[]; muted?: boolean; label?: string }) { | |
| 95 | if (items.length === 0) return null; | |
| 96 | return ( | |
| 97 | <ul className="flex flex-wrap gap-1.5" aria-label={label}> | |
| 98 | {items.map((item) => ( | |
| 99 | <li key={item} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}> | |
| 100 | {item} | |
| 101 | </li> | |
| 102 | ))} | |
| 103 | </ul> | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | /** A section's heading, with a count or a link at its end, and a line under it. */ | |
| 108 | export function SectionHead({ id, title, aside, children }: { id: string; title: ReactNode; aside?: ReactNode; children?: ReactNode }) { | |
| 109 | return ( | |
| 110 | <div className="mb-3"> | |
| 111 | <div className="flex items-baseline justify-between gap-3"> | |
| 112 | <h2 id={id} className="flex items-center gap-2 text-base font-semibold tracking-tight"> | |
| 113 | {title} | |
| 114 | </h2> | |
| 115 | {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>} | |
| 116 | </div> | |
| 117 | {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>} | |
| 118 | </div> | |
| 119 | ); | |
| 120 | } | |
| 121 | ||
| 122 | /** The grid listings sit in. */ | |
| 123 | export function ListingGrid({ children }: { children: ReactNode }) { | |
| 124 | return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{children}</div>; | |
| 125 | } | |
| 126 | ||
| 127 | /** Discover's banner: what is open to add here, in a wash of the accent. */ | |
| 128 | export function FeaturedBanner({ title, children, action }: { title: ReactNode; children: ReactNode; action?: ReactNode }) { | |
| 129 | return ( | |
| 130 | <Card asChild className="relative overflow-hidden p-5 sm:p-6"> | |
| 131 | <section aria-label="Featured"> | |
| 132 | <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_100%_0%,var(--color-accent)_0%,transparent_55%)] opacity-[0.12]" /> | |
| 133 | <div className="relative flex flex-wrap items-end justify-between gap-4"> | |
| 134 | <div className="min-w-0 max-w-2xl"> | |
| 135 | <h2 className="text-lg font-semibold tracking-tight">{title}</h2> | |
| 136 | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 137 | </div> | |
| 138 | {action} | |
| 139 | </div> | |
| 140 | </section> | |
| 141 | </Card> | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | /** A plugin's or a skill's availability as a badge, with why it isn't available here on hover. */ | |
| 146 | export function AvailabilityBadge({ availability }: { availability: PluginAvailability }) { | |
| 147 | const { label, tone } = availabilityWord(availability); | |
| 148 | const hint = | |
| 149 | availability.kind === "unavailable" | |
| 150 | ? availability.why | |
| 151 | : availability.kind === "soon" | |
| 152 | ? "g1t's own, announced and not released yet. Nobody can install it until it is." | |
| 153 | : availability.kind === "installed" && !availability.enabled | |
| 154 | ? "Installed and switched off: its skills, agents and connectors are kept but reach no agent until an owner switches it on." | |
| 155 | : null; | |
| 156 | const badge = <Badge tone={tone}>{label}</Badge>; | |
| 157 | if (!hint) return badge; | |
| 158 | return ( | |
| 159 | <Hint label={hint}> | |
| 160 | <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40"> | |
| 161 | {badge} | |
| 162 | </span> | |
| 163 | </Hint> | |
| 164 | ); | |
| 165 | } | |
| 166 | ||
| 167 | /** A connector's state as a badge: Connected, Needs sign-in, Needs a key, or Problem with what is wrong on hover. */ | |
| 168 | export function StateBadge({ state, problem }: { state: ConnectorState; problem?: string | null }) { | |
| 169 | const { label, tone } = CONNECTOR_STATES[state]; | |
| 170 | const badge = <Badge tone={tone}>{label}</Badge>; | |
| 171 | if (!problem) return badge; | |
| 172 | return ( | |
| 173 | <Hint label={problem}> | |
| 174 | <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40"> | |
| 175 | {badge} | |
| 176 | </span> | |
| 177 | </Hint> | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | /** Something to know before connecting: an information mark with the words on hover. */ | |
| 182 | export function Caveat({ text }: { text: string | null | undefined }) { | |
| 183 | if (!text) return null; | |
| 184 | return ( | |
| 185 | <Hint label={text}> | |
| 186 | <span tabIndex={0} aria-label="Something to know" className="inline-flex rounded-full text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent/40"> | |
| 187 | <Info size={14} /> | |
| 188 | </span> | |
| 189 | </Hint> | |
| 190 | ); | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * A menu that picks one of `options` (a category, a marketplace, a sort), | |
| 195 | * as a quiet button naming the choice; `all` is the word for none. | |
| 196 | */ | |
| 197 | export function FilterMenu({ label, all, value, options, onChange }: { label: string; all: string; value: string | null; options: readonly { value: string; label: string }[]; onChange: (value: string | null) => void }) { | |
| 198 | const chosen = options.find((option) => option.value === value); | |
| 199 | return ( | |
| 200 | <DropdownMenu> | |
| 201 | <DropdownMenuTrigger asChild> | |
| 202 | <Button type="button" variant={chosen ? "soft" : "outline"} size="sm" aria-label={`${label}: ${chosen?.label ?? all}`}> | |
| 203 | <span className="text-muted">{label}</span> | |
| 204 | <span className="max-w-40 truncate">{chosen?.label ?? all}</span> | |
| 205 | <ChevronDown size={13} className="text-faint" /> | |
| 206 | </Button> | |
| 207 | </DropdownMenuTrigger> | |
| 208 | <DropdownMenuContent align="start"> | |
| 209 | <DropdownMenuLabel>{label}</DropdownMenuLabel> | |
| 210 | <DropdownMenuRadioGroup value={value ?? ""} onValueChange={(next) => onChange(next || null)}> | |
| 211 | <DropdownMenuRadioItem value="">{all}</DropdownMenuRadioItem> | |
| 212 | {options.map((option) => ( | |
| 213 | <DropdownMenuRadioItem key={option.value} value={option.value}> | |
| 214 | {option.label} | |
| 215 | </DropdownMenuRadioItem> | |
| 216 | ))} | |
| 217 | </DropdownMenuRadioGroup> | |
| 218 | </DropdownMenuContent> | |
| 219 | </DropdownMenu> | |
| 220 | ); | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Where something reaches, and the control that changes it: a menu of | |
| 225 | * every agent, each team and each agent, checked where it already reaches. | |
| 226 | * Checking gives it there; unchecking takes it back. Without `targets` | |
| 227 | * (someone who may not change it) the line alone shows. | |
| 228 | */ | |
| 229 | export function AvailableTo({ | |
| 230 | reach, | |
| 231 | targets, | |
| 232 | busy = false, | |
| 233 | onChange, | |
| 234 | size = "xs", | |
| 235 | }: { | |
| 236 | reach: readonly Reach[]; | |
| 237 | targets: ReachTargets | null; | |
| 238 | busy?: boolean; | |
| 239 | /** Give it where `option` says (`checked`), or take it back from there (`option.reach`). */ | |
| 240 | onChange?: (option: ReachOption, checked: boolean) => void; | |
| 241 | size?: "xs" | "sm"; | |
| 242 | }) { | |
| 243 | const words = reachWords(reach); | |
| 244 | const options = targets ? reachOptions(reach, targets) : []; | |
| 245 | if (!targets || !onChange || options.length === 0) { | |
| 246 | return ( | |
| 247 | <span className={cn("inline-flex items-center gap-1.5 text-muted", size === "xs" ? "text-xs" : "text-[0.8125rem]")}> | |
| 248 | <Users size={size === "xs" ? 12 : 13} className="shrink-0 text-faint" aria-hidden /> | |
| 249 | <span className="truncate">{reach.length === 0 ? "Not available to any agent yet" : `Available to ${words}`}</span> | |
| 250 | </span> | |
| 251 | ); | |
| 252 | } | |
| 253 | return ( | |
| 254 | <DropdownMenu> | |
| 255 | <DropdownMenuTrigger asChild> | |
| 256 | <Button type="button" variant="outline" size={size} disabled={busy} className="max-w-full" aria-label={`Available to ${words}; change where it reaches`}> | |
| 257 | <Users size={size === "xs" ? 12 : 13} className="text-faint" /> | |
| 258 | <span className="text-muted">Available to</span> | |
| 259 | <span className="truncate">{words}</span> | |
| 260 | <ChevronDown size={12} className="text-faint" /> | |
| 261 | </Button> | |
| 262 | </DropdownMenuTrigger> | |
| 263 | <DropdownMenuContent align="start" className="min-w-56"> | |
| 264 | <DropdownMenuLabel>Available to</DropdownMenuLabel> | |
| 265 | {options.map((option) => ( | |
| 266 | <DropdownMenuCheckboxItem key={option.value} checked={option.checked} onCheckedChange={(checked) => onChange(option, checked === true)}> | |
| 267 | {option.label} | |
| 268 | </DropdownMenuCheckboxItem> | |
| 269 | ))} | |
| 270 | </DropdownMenuContent> | |
| 271 | </DropdownMenu> | |
| 272 | ); | |
| 273 | } | |
| 274 | ||
| 275 | /** "3 waiting": open requests for a listing, for owners, leading to the requests page. */ | |
| 276 | export function Waiting({ slug, count }: { slug: string; count: number }) { | |
| 277 | if (count === 0) return null; | |
| 278 | return ( | |
| 279 | <Link to={customizePath("requests", slug)} className="text-xs text-warn hover:underline"> | |
| 280 | {count} {count === 1 ? "request" : "requests"} | |
| 281 | </Link> | |
| 282 | ); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Asking the workspace's owners to add something: a dialog that says what | |
| 287 | * is asked for, with an optional note, sent to the requests page. Once | |
| 288 | * sent, it says so. | |
| 289 | */ | |
| 290 | export function RequestButton({ slug, listing, name, requested, className, size = "sm" }: { slug: string; listing: string; name: string; requested: boolean; className?: string; size?: "xs" | "sm" }) { | |
| 291 | const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` }); | |
| 292 | const [open, setOpen] = useState(false); | |
| 293 | const sent = fetcher.state === "idle" && fetcher.data?.error === null; | |
| 294 | useEffect(() => { | |
| 295 | if (sent) setOpen(false); | |
| 296 | }, [sent]); | |
| 297 | if (requested || sent) { | |
| 298 | return ( | |
| 299 | <Hint label="The workspace's owners have your request. You'll hear when they answer."> | |
| 300 | <span tabIndex={0} className={cn(buttonVariants({ variant: "outline", size }), "text-muted hover:border-line hover:bg-transparent hover:text-muted", className)}> | |
| 301 | <Clock size={13} /> | |
| 302 | Requested | |
| 303 | </span> | |
| 304 | </Hint> | |
| 305 | ); | |
| 306 | } | |
| 307 | return ( | |
| 308 | <Dialog open={open} onOpenChange={setOpen}> | |
| 309 | <DialogTrigger asChild> | |
| 310 | <Button variant="outline" size={size} className={className}> | |
| 311 | <Send size={13} /> | |
| 312 | Request | |
| 313 | </Button> | |
| 314 | </DialogTrigger> | |
| 315 | <DialogContent> | |
| 316 | <fetcher.Form method="post" action={customizePath("requests", slug)} className="grid gap-4"> | |
| 317 | <DialogHeader> | |
| 318 | <DialogTitle>Ask to add {name}</DialogTitle> | |
| 319 | <DialogDescription>Only the workspace's owners add skills, connectors and plugins. Each of them is notified, and you hear back when one answers.</DialogDescription> | |
| 320 | </DialogHeader> | |
| 321 | <input type="hidden" name="intent" value="request" /> | |
| 322 | <input type="hidden" name="listing" value={listing} /> | |
| 323 | <label className="grid gap-1.5 text-sm"> | |
| 324 | <span className="font-medium"> | |
| 325 | Why you want it <span className="font-normal text-faint">(optional)</span> | |
| 326 | </span> | |
| 327 | <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" /> | |
| 328 | </label> | |
| 329 | {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 330 | <DialogFooter> | |
| 331 | <SubmitButton fetcher={fetcher} pending="Sending…"> | |
| 332 | <Send size={14} /> | |
| 333 | Send request | |
| 334 | </SubmitButton> | |
| 335 | </DialogFooter> | |
| 336 | </fetcher.Form> | |
| 337 | </DialogContent> | |
| 338 | </Dialog> | |
| 339 | ); | |
| 340 | } | |
| 341 | ||
| 342 | /** A request's status, in a word. */ | |
| 343 | export function RequestStatus({ request }: { request: InstallRequest }) { | |
| 344 | const tone: BadgeTone = request.status === "open" ? "warn" : request.status === "done" ? "success" : "neutral"; | |
| 345 | return <Badge tone={tone}>{request.status === "open" ? "Waiting" : request.status === "done" ? "Added" : "Turned down"}</Badge>; | |
| 346 | } | |
| 347 | ||
| 348 | /** | |
| 349 | * One request as a row: what was asked for, by whom, why and when; for an | |
| 350 | * owner, while it waits, the way to add it, mark it added, or turn it down. | |
| 351 | */ | |
| 352 | export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) { | |
| 353 | const fetcher = useFetcher<{ error: string | null }>(); | |
| 354 | return ( | |
| 355 | <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap"> | |
| 356 | <div className="min-w-0 grow"> | |
| 357 | <p className="flex flex-wrap items-center gap-2 text-sm"> | |
| 358 | <span className="font-medium">{request.name}</span> | |
| 359 | <span className="text-xs text-faint">{kindWord(request.kind)}</span> | |
| 360 | <RequestStatus request={request} /> | |
| 361 | </p> | |
| 362 | <p className="mt-0.5 text-xs text-muted"> | |
| 363 | {owner ? `@${request.requested_by} asked ` : "You asked "} | |
| 364 | <TimeAgo at={request.requested_at} /> | |
| 365 | {request.resolved_by && request.status !== "open" && ( | |
| 366 | <> | |
| 367 | {" · "} | |
| 368 | {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by} | |
| 369 | </> | |
| 370 | )} | |
| 371 | </p> | |
| 372 | {request.note && <p className="mt-1.5 max-w-xl border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>} | |
| 373 | {fetcher.data?.error && ( | |
| 374 | <div className="mt-2"> | |
| 375 | <ErrorText>{fetcher.data.error}</ErrorText> | |
| 376 | </div> | |
| 377 | )} | |
| 378 | </div> | |
| 379 | {owner && request.status === "open" && ( | |
| 380 | <fetcher.Form method="post" action={customizePath("requests", slug)} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0"> | |
| 381 | <input type="hidden" name="intent" value="resolve" /> | |
| 382 | <input type="hidden" name="id" value={request.id} /> | |
| 383 | {addTo && ( | |
| 384 | <ButtonLink to={addTo} size="sm"> | |
| 385 | <Plus size={14} /> | |
| 386 | {request.kind === "connector" || request.kind === "integration" ? "Connect" : request.kind === "skill" ? "Add" : "Install"} | |
| 387 | </ButtonLink> | |
| 388 | )} | |
| 389 | <SubmitButton fetcher={fetcher} name="status" value="done" variant="outline" size="sm" pending="Saving…"> | |
| 390 | <Check size={14} /> | |
| 391 | Mark added | |
| 392 | </SubmitButton> | |
| 393 | <SubmitButton fetcher={fetcher} name="status" value="declined" variant="outline" size="sm" pending="Saving…"> | |
| 394 | <X size={14} /> | |
| 395 | Turn down | |
| 396 | </SubmitButton> | |
| 397 | </fetcher.Form> | |
| 398 | )} | |
| 399 | </li> | |
| 400 | ); | |
| 401 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Owners add a marketplace by address: `owner/repo` on GitHub or a | |
| 405 | * repository here, a git address, or a link to its marketplace.json. Posted | |
| 406 | * to the Marketplaces page, from wherever the dialog opens. | |
| 407 | */ | |
| 408 | export function AddMarketplaceDialog({ slug, dialog }: { slug: string; dialog: ReturnType<typeof useDialogFetcher> }) { | |
| 409 | const { fetcher, open, setOpen, error, busy } = dialog; | |
| 410 | const id = useId(); | |
| 411 | return ( | |
| 412 | <Dialog open={open} onOpenChange={setOpen}> | |
| 413 | <DialogContent> | |
| 414 | <DialogHeader> | |
| 415 | <DialogTitle>Add a marketplace</DialogTitle> | |
| 416 | <DialogDescription> | |
| 417 | A repository with <code className="font-mono text-xs">.claude-plugin/marketplace.json</code>: on GitHub as owner/repo, a repository here as workspace/name, a git address, or a link to the file itself. Its plugins | |
| 418 | show in Discover, in the formats Claude Code reads. | |
| 419 | </DialogDescription> | |
| 420 | </DialogHeader> | |
| 421 | <fetcher.Form method="post" action={customizePath("marketplaces", slug)} className="grid gap-5"> | |
| 422 | <input type="hidden" name="intent" value="add" /> | |
| 423 | <Field> | |
| 424 | <FieldLabel htmlFor={`${id}-address`}>Address</FieldLabel> | |
| 425 | <Input id={`${id}-address`} name="address" placeholder="anthropics/claude-plugins-official" required autoComplete="off" spellCheck={false} className="font-mono" /> | |
| 426 | </Field> | |
| 427 | <Field> | |
| 428 | <FieldLabel htmlFor={`${id}-name`}> | |
| 429 | Name <span className="font-normal text-faint">(optional)</span> | |
| 430 | </FieldLabel> | |
| 431 | <Input id={`${id}-name`} name="name" placeholder="As its manifest names it" autoComplete="off" spellCheck={false} /> | |
| 432 | <FieldDescription>Plugins are named plugin@marketplace, so a name you choose is the one your people see.</FieldDescription> | |
| 433 | </Field> | |
| 434 | <FieldError>{error}</FieldError> | |
| 435 | <DialogFooter> | |
| 436 | <Button type="button" variant="outline" onClick={() => setOpen(false)}> | |
| 437 | Cancel | |
| 438 | </Button> | |
| 439 | <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "add" }} busy={busy} pending="Reading it…"> | |
| 440 | Add marketplace | |
| 441 | </SubmitButton> | |
| 442 | </DialogFooter> | |
| 443 | </fetcher.Form> | |
| 444 | </DialogContent> | |
| 445 | </Dialog> | |
| 446 | ); | |
| 447 | } | |
| 448 | ||
| 449 | /** What a page says it can't show: the agents service didn't answer. */ | |
| 450 | export function NoAnswer({ what }: { what: string }) { | |
| 451 | return ( | |
| 452 | <Card asChild tone="plain" className="border-dashed px-4 py-8 text-center text-sm text-muted"> | |
| 453 | <p>{what} can't be shown right now. The agents service didn't answer; reload in a minute.</p> | |
| 454 | </Card> | |
| 455 | ); | |
| 456 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.