Skip to content
390 linesCodeBlameRaw
1/**
2 * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3 * agents and integrations with what the workspace has of each, the button
4 * that adds one (owners) or asks an owner to (everyone else), and a
5 * request as owners and askers see it.
6 */
7import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react";
8import { type ReactNode, useEffect, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts";
12
13import { PixelCreature } from "./agent-avatar";
14import { ConnectorMark } from "./connectors";
15import { Badge } from "./ui/badge";
16import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
17import { Hint } from "./ui/hint";
18import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
19import { type AgentListing, type ExtensionListing, type IntegrationListing, TIERS, catalogAgentPath, extensionPath, hirePath, marketplacePath } from "../lib/marketplace";
20import { cn } from "../lib/cn";
21
22const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors";
23export const ACTION = {
24 primary: `${SMALL} bg-fg text-bg hover:bg-fg-hover`,
25 quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`,
26};
27
28/** Who stands behind a listing: a small badge, with the sentence on hover. */
29export function TierBadge({ tier }: { tier: ListingTier }) {
30 return (
31 <Hint label={TIERS[tier].about}>
32 <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
33 <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge>
34 </span>
35 </Hint>
36 );
37}
38
39/** "Coming", on what is planned and not built. */
40export function ComingBadge() {
41 return <Badge tone="neutral">Coming</Badge>;
42}
43
44/** A section's heading, with a count or a link at its end. */
45export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) {
46 return (
47 <div className="mb-3">
48 <div className="flex items-baseline justify-between gap-3">
49 <h2 id={id} className="text-base font-semibold tracking-tight">
50 {title}
51 </h2>
52 {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
53 </div>
54 {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
55 </div>
56 );
57}
58
59/** A link at the end of a section's heading. */
60export function SeeAll({ to, children }: { to: string; children: ReactNode }) {
61 return (
62 <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg">
63 {children}
64 <ArrowRight size={13} />
65 </Link>
66 );
67}
68
69/**
70 * Asking the workspace's owners to add something: a dialog with an
71 * optional note, sent to the requests route. Once sent, it says so.
72 */
73export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) {
74 const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
75 const [open, setOpen] = useState(false);
76 const sent = fetcher.state === "idle" && fetcher.data?.error === null;
77 useEffect(() => {
78 if (sent) setOpen(false);
79 }, [sent]);
80 if (requested || sent) {
81 return (
82 <Hint label="The workspace's owners have your request. You'll hear when they answer.">
83 <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
84 <Clock size={13} />
85 Requested
86 </span>
87 </Hint>
88 );
89 }
90 return (
91 <Dialog open={open} onOpenChange={setOpen}>
92 <DialogTrigger asChild>
93 <button type="button" className={cn(ACTION.quiet, className)}>
94 <Send size={13} />
95 Request
96 </button>
97 </DialogTrigger>
98 <DialogContent>
99 <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
100 <DialogHeader>
101 <DialogTitle>Ask to add {name}</DialogTitle>
102 <DialogDescription>Only the workspace's owners add agents and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
103 </DialogHeader>
104 <input type="hidden" name="intent" value="request" />
105 <input type="hidden" name="listing" value={listing} />
106 <label className="grid gap-1.5 text-sm">
107 <span className="font-medium">
108 Why you want it <span className="font-normal text-faint">(optional)</span>
109 </span>
110 <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
111 </label>
112 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
113 <DialogFooter>
114 <SubmitButton fetcher={fetcher} pending="Sending…">
115 <Send size={14} />
116 Send request
117 </SubmitButton>
118 </DialogFooter>
119 </fetcher.Form>
120 </DialogContent>
121 </Dialog>
122 );
123}
124
125/** "3 waiting": open requests for a listing, for owners. */
126function Waiting({ slug, count }: { slug: string; count: number }) {
127 if (count === 0) return null;
128 return (
129 <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
130 {count} {count === 1 ? "request" : "requests"}
131 </Link>
132 );
133}
134
135/** What an owner or a member can do with a role in the catalog. */
136export function AgentAction({ listing, slug, owner, className }: { listing: AgentListing; slug: string; owner: boolean; className?: string }) {
137 if (owner) {
138 return (
139 <Link to={hirePath(slug, listing.template.id)} className={cn(listing.hired.length > 0 ? ACTION.quiet : ACTION.primary, className)}>
140 <Plus size={14} />
141 {listing.hired.length > 0 ? "Add another" : "Add to workspace"}
142 </Link>
143 );
144 }
145 return <RequestButton slug={slug} listing={listing.ref} name={`${listing.template.display_name}, ${listing.template.title}`} requested={listing.requested} className={className} />;
146}
147
148/** A role in the agent catalog, as a card: its face, the name it suggests, what it does, and what the workspace has of it. */
149export function AgentCard({ listing, slug, owner }: { listing: AgentListing; slug: string; owner: boolean }) {
150 const { template, hired } = listing;
151 return (
152 <article className="group relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
153 <div className="flex items-start gap-3">
154 <PixelCreature seed={template.handle} size={44} />
155 <div className="min-w-0 grow">
156 <h3 className="truncate text-sm font-semibold">
157 <Link to={catalogAgentPath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
158 {template.display_name}
159 </Link>
160 </h3>
161 <p className="truncate text-xs text-muted">
162 {template.title}
163 </p>
164 </div>
165 <TierBadge tier="official" />
166 </div>
167 <ul className="mt-3 grow space-y-1">
168 {template.responsibilities.slice(0, 3).map((duty) => (
169 <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted">
170 <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" />
171 <span className="line-clamp-1">{duty}</span>
172 </li>
173 ))}
174 </ul>
175 <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
176 <span className="min-w-0 truncate text-xs text-faint">
177 {/* What waits on an owner says more than what isn't there. */}
178 {owner && listing.waiting > 0 && hired.length === 0 ? (
179 <Waiting slug={slug} count={listing.waiting} />
180 ) : hired.length === 0 ? (
181 "Not added"
182 ) : hired.length === 1 ? (
183 `@${hired[0]!.handle} works here`
184 ) : (
185 `${hired.length} work here`
186 )}
187 </span>
188 <AgentAction listing={listing} slug={slug} owner={owner} />
189 </div>
190 </article>
191 );
192}
193
194/** What an owner or a member can do with an integration. */
195export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
196 if (listing.connected) {
197 return listing.href ? (
198 <Link to={listing.href} className={ACTION.quiet}>
199 Manage
200 </Link>
201 ) : null;
202 }
203 if (owner) {
204 return listing.href ? (
205 <Link to={listing.href} className={ACTION.primary}>
206 <Plus size={14} />
207 Connect
208 </Link>
209 ) : null;
210 }
211 return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />;
212}
213
214/** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */
215export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
216 const { view, connected } = listing;
217 return (
218 <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
219 <div className="flex items-start gap-3">
220 <ConnectorMark view={view} size={40} />
221 <div className="min-w-0 grow">
222 <h3 className="truncate text-sm font-semibold">{view.name}</h3>
223 <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
224 </div>
225 </div>
226 {view.capabilities.length > 0 && (
227 <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does">
228 {view.capabilities.map((capability) => (
229 <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted">
230 {capability}
231 </li>
232 ))}
233 </ul>
234 )}
235 <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
236 <span className="flex min-w-0 items-center gap-1.5 text-xs">
237 {connected ? (
238 connected.problem ? (
239 <Hint label={connected.problem}>
240 <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none">
241 <CircleAlert size={13} className="shrink-0" />
242 <span className="truncate">Needs attention</span>
243 </span>
244 </Hint>
245 ) : (
246 <span className="inline-flex min-w-0 items-center gap-1 text-success">
247 <Check size={13} className="shrink-0" />
248 <span className="truncate">Connected</span>
249 </span>
250 )
251 ) : (
252 <span className="truncate text-faint">Not connected</span>
253 )}
254 {owner && listing.waiting > 0 && (
255 <>
256 <span className="text-faint">·</span>
257 <Waiting slug={slug} count={listing.waiting} />
258 </>
259 )}
260 </span>
261 <IntegrationAction listing={listing} slug={slug} owner={owner} />
262 </div>
263 </article>
264 );
265}
266
267/** An extension's mark: its initials on a steady colour, until listings bring their own icon. */
268export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) {
269 return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
270}
271
272/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */
273export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
274 const fetcher = useFetcher<{ error: string | null }>();
275 const { manifest, install } = listing;
276 if (manifest.status !== "available") {
277 return (
278 <Hint label="Not published yet. It can be installed from its first release.">
279 <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
280 Soon
281 </span>
282 </Hint>
283 );
284 }
285 if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>;
286 if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />;
287 return (
288 <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
289 <input type="hidden" name="intent" value="install" />
290 <input type="hidden" name="extension" value={manifest.id} />
291 <SubmitButton fetcher={fetcher} className={cn(ACTION.primary, className)} pending="Installing…">
292 <Plus size={14} />
293 Install
294 </SubmitButton>
295 </fetcher.Form>
296 );
297}
298
299/** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */
300export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
301 const { manifest } = listing;
302 return (
303 <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
304 <div className="flex items-start gap-3">
305 <ExtensionMark manifest={manifest} />
306 <div className="min-w-0 grow">
307 <h3 className="truncate text-sm font-semibold">
308 <Link to={extensionPath(slug, manifest.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
309 {manifest.name}
310 </Link>
311 </h3>
312 <p className="truncate text-xs text-muted">
313 {manifest.publisher.name} · {manifest.category}
314 </p>
315 </div>
316 <span className="relative">
317 <TierBadge tier={manifest.publisher.tier} />
318 </span>
319 </div>
320 <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
321 <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
322 <span className="min-w-0 truncate text-xs text-faint">{manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`}</span>
323 <ExtensionAction listing={listing} slug={slug} owner={owner} />
324 </div>
325 </article>
326 );
327}
328
329/** A request's status, in a word. */
330export function RequestStatus({ request }: { request: InstallRequest }) {
331 if (request.status === "open") return <Badge tone="warn">Waiting</Badge>;
332 if (request.status === "done") return <Badge tone="success">Added</Badge>;
333 return <Badge tone="neutral">Turned down</Badge>;
334}
335
336/**
337 * One request as a row: what was asked for, by whom, why and when; for an
338 * owner, while it waits, the way to add it, mark it added, or turn it down.
339 */
340export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
341 const fetcher = useFetcher<{ error: string | null }>();
342 const action = marketplacePath(slug, "requests");
343 return (
344 <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
345 <div className="min-w-0 grow">
346 <p className="flex flex-wrap items-center gap-2 text-sm">
347 <span className="font-medium">{request.name}</span>
348 <span className="text-xs text-faint">{request.kind === "agent" ? "Agent" : request.kind === "extension" ? "Extension" : "Integration"}</span>
349 <RequestStatus request={request} />
350 </p>
351 <p className="mt-0.5 text-xs text-muted">
352 {owner ? `@${request.requested_by} asked ` : "You asked "}
353 <TimeAgo at={request.requested_at} />
354 {request.resolved_by && request.status !== "open" && (
355 <>
356 {" · "}
357 {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
358 </>
359 )}
360 </p>
361 {request.note && <p className="mt-1.5 max-w-xl rounded-md border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
362 {fetcher.data?.error && (
363 <div className="mt-2">
364 <ErrorText>{fetcher.data.error}</ErrorText>
365 </div>
366 )}
367 </div>
368 {owner && request.status === "open" && (
369 <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
370 <input type="hidden" name="intent" value="resolve" />
371 <input type="hidden" name="id" value={request.id} />
372 {addTo && (
373 <Link to={addTo} className={ACTION.primary}>
374 <Plus size={14} />
375 {request.kind === "agent" ? "Add" : request.kind === "extension" ? "Install" : "Connect"}
376 </Link>
377 )}
378 <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…">
379 <Check size={14} />
380 Mark added
381 </SubmitButton>
382 <SubmitButton fetcher={fetcher} name="status" value="declined" className={ACTION.quiet} pending="Saving…">
383 <X size={14} />
384 Turn down
385 </SubmitButton>
386 </fetcher.Form>
387 )}
388 </li>
389 );
390}