| 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 | } |