Skip to content

Commit

Every Marketplace listing says who builds it and whether you can add it: a tier (Official, Verified, Community, Internal) with what it means, and an availability (Available, Connected, Soon, or Not available here with why) that never looks like a button that doesn't work; Extensions and Integrations are grouped and filtered by both, each integration has a page with what it lets agents do today and what's Soon, and the marketplace guide says how.

syntaqxcommitted Parentabb4b46Browse files
15 files+1250−3280/15 viewed
+54−16
2222 *Marketplace*. Its address is `g1t.sh/<workspace>/-/marketplace`. The
2323 Marketplace isn't in the dock itself.
2424
25+## Who builds what, and whether you can add it
26+
27+Every listing, on every card, page and request, carries two labels: its
28+**tier**, which says who stands behind it, and its **availability**,
29+which says whether it can be added here, now. Hover or focus a label to
30+read what it means. **Discover** explains both under **Who builds what**.
31+
32+| Tier | Colour | Who stands behind it |
33+| --- | --- | --- |
34+| **Official** | Lavender | g1t. Built and supported by g1t. Every integration is Official, as are g1t's own extensions. |
35+| **Verified** | Green | A reviewed publisher. Its code and the scopes it asks for are checked before it is listed. |
36+| **Community** | Amber | Anyone. Its pages run sandboxed on the user-content domain (g1tusercontent.com on g1t.sh), and its server code stays off until that sandbox is hardened. |
37+| **Internal** | Blue | Your own people and agents. Promoted for your workspace only. |
38+
39+Today everything listed is Official. The **Extensions** and
40+**Integrations** tabs still show a section for each tier, so a tier
41+with nothing in it says so: *No verified publishers yet*, *No community
42+extensions yet*, *Nothing built in this workspace yet*.
43+
44+| Availability | What it means |
45+| --- | --- |
46+| **Available** | It can be added now. Owners add it for everyone; anyone else asks an owner with **Request**. |
47+| **Connected** or **Installed** | This workspace has it. An installed extension that was switched off says **Switched off**. |
48+| **Soon** | Planned, not built yet. Nobody can add it until it is released, so its card is drawn dashed and muted, with nothing to press. Its page still shows what it will do. |
49+| **Not available here** | This workspace or this g1t lacks something it needs, and the listing says what. For example, GitHub needs a GitHub App, which a self-hosted g1t adds before anyone can connect it. |
50+
51+### Filter by tier and availability
52+
53+The **Extensions** and **Integrations** tabs have two rows of filters,
54+**Who builds it** and **Availability**, each option with how many
55+listings it has. A filter is kept in the address, such as
56+`/<workspace>/-/marketplace/integrations?tier=official&availability=soon`,
57+so a filtered list can be shared.
58+
2559 ## Who can add things
2660
2761 Every member of a workspace can browse the Marketplace. Only its
3367
3468 | Tab | What it shows |
3569 | --- | --- |
36−| **Discover** | Featured extensions, integrations with the connected ones first, starter kits, and connected systems. |
37−| **Extensions** | Every extension listed: published ones first, then g1t's own that are coming, then connected systems, and how extensions are shared. |
38−| **Integrations** | Connected integrations, the ones you can connect now, the ones each person connects for themselves, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. |
70+| **Discover** | Featured extensions, who builds what, integrations with the connected ones first, starter kits, and connected systems. |
71+| **Extensions** | Every extension, by tier: g1t's own (connected systems apart), then Verified, Community and Internal. Filters by tier and availability, and how extensions are shared. |
72+| **Integrations** | Every integration, by tier. Under Official: connected ones, the ones you can connect now, any not available here, the ones each person connects for themselves, and the ones coming. Search by name, by what it does, or by a word such as *tracker*, and filter by tier and availability. |
3973 | **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. |
4074
4175 ## Extensions
72106 CRM), **Engineering extras** (On-call, Helpdesk bridge) and **People and
73107 operations** (Recruiting, ERP bridge). They are on **Discover**, marked
74108 **Soon** until then.
75−
76−### Who stands behind a listing
77−
78−| Tier | Who | |
79−| --- | --- | --- |
80−| **Official** | g1t | Built and supported by g1t. |
81−| **Verified** | A reviewed publisher | Code and permissions are checked before it is listed. |
82−| **Community** | Anyone | Shared from a public repository. Pages run sandboxed; server code runs on the publisher's own servers until hosted code has an isolated sandbox. |
83−| **Internal** | Your workspace | Built by your own people and agents, seen only by your workspace. |
84109
85110 ### What an extension's page tells you
86111
87−Before anything is installed, its page lists:
112+Its header says its tier, who publishes it, its availability and its
113+category, such as *Official · by g1t · Soon · Customers*. Before anything
114+is installed, its page lists:
88115
89116 - **What it adds**: its pages, the cards it shows in chat, the agent roles
90117 it brings, the tools agents get, and the notifications it sends.
142169 **Manage** opens it once it is connected. **Needs attention** means a
143170 connection's last check failed: hover it to see why.
144171
172+Each integration has its own page,
173+`/<workspace>/-/marketplace/integrations/<id>`, opened from its card. Its
174+header says its tier, publisher, availability and category, and **What
175+it lets agents and people do** sets out, for each way it is connected
176+(for the whole workspace, or for each person), what works **Today** and
177+what is **Soon**. Linear, for example, lets agents read and write back
178+for a workspace today, while each person's Linear inbox is Soon. When an
179+integration is not available here, its page says why.
180+
145181 Some integrations each person connects for themselves, such as a GitHub
146182 account or an MCP client. They are listed under **Connected by each
147183 person**, and **Connect yours** opens your own
153189 ## Ask an owner to add something
154190
155191 1. Choose **Request** on an extension or an integration.
156−2. Say why you want it, if you like, and choose **Send request**.
192+2. Check what you are asking for: the dialog shows its tier, who
193+ publishes it, and that it is available.
194+3. Say why you want it, if you like, and choose **Send request**.
157195
158196 Every owner is notified. The button then reads **Requested**, and **Your
159197 requests** lists it until an owner answers; you are notified when one does.
173211 ## Apps
174212
175213 What the workspace added from the Marketplace that you can use is under
176−**Apps**: today, the integrations it connected. Pin the ones you want to
177−your dock. See [Apps](/guides/workspaces/#apps).
214+**Apps**: today, the integrations it connected. Each shows its tier, as
215+in the Marketplace. Pin the ones you want to your dock. See [Apps](/guides/workspaces/#apps).
+8−4
33 import { Link, useFetcher, useFetchers } from "react-router";
44
55 import { ConnectorMark } from "./connectors";
6+import { TierBadge } from "./marketplace";
67 import { Skeleton } from "./ui/skeleton";
78 import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps";
89 import { cn } from "../lib/cn";
9596 </span>
9697 {card ? (
9798 <span className="min-w-0">
98− <span className="block truncate text-sm font-medium text-fg">{app.name}</span>
99+ <span className="flex min-w-0 items-center gap-2">
100+ <span className="truncate text-sm font-medium text-fg">{app.name}</span>
101+ <TierBadge tier={app.tier} focusable={false} />
102+ </span>
99103 <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span>
100104 </span>
101105 ) : (
102− <span className="w-full min-w-0">
103− <span className="block truncate text-xs font-medium text-fg-soft">{app.name}</span>
104− {!app.usable && <span className="block truncate text-[0.625rem] text-accent">Request access</span>}
106+ <span className="flex w-full min-w-0 flex-col items-center gap-1">
107+ <span className="block w-full truncate text-xs font-medium text-fg-soft">{app.name}</span>
108+ {app.usable ? <TierBadge tier={app.tier} focusable={false} className="px-1 text-[0.5625rem] leading-3.5" /> : <span className="block truncate text-[0.625rem] text-accent">Request access</span>}
105109 </span>
106110 )}
107111 </>
+508−97
11 /**
22 * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3− * extensions and integrations with what the workspace has of each, the
4− * button that adds one (owners) or asks an owner to (everyone else),
5− * starter kits, and a request as owners and askers see it.
3+ * extensions and integrations, each with who stands behind it (its tier)
4+ * and whether it can be added here (its availability), the button that
5+ * adds one (owners) or asks an owner to (everyone else), the filters and
6+ * the legend for both, starter kits, and a request as owners and askers
7+ * see it.
68 */
7−import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react";
9+import { ArrowRight, Ban, Check, CircleAlert, Clock, Plus, Power, Send, X } from "lucide-react";
810 import { type ReactNode, useEffect, useState } from "react";
9−import { Link, useFetcher } from "react-router";
11+import { Link, useFetcher, useLocation, useSearchParams } from "react-router";
1012
11−import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts";
13+import type { ExtensionManifest, InstallRequest, ListingKind, ListingTier } from "@g1t/contracts";
14+import { LISTING_TIERS } from "@g1t/contracts/marketplace";
1215
1316 import { ConnectorMark } from "./connectors";
1417 import { Badge } from "./ui/badge";
1518 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
1619 import { Hint } from "./ui/hint";
1720 import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
18−import { type ExtensionListing, type IntegrationListing, type StarterKit, TIERS, extensionPath, marketplacePath } from "../lib/marketplace";
21+import {
22+ AVAILABILITIES,
23+ AVAILABILITY,
24+ type Availability,
25+ type ExtensionListing,
26+ type IntegrationListing,
27+ type ListingFilters,
28+ type StarterKit,
29+ TIERS,
30+ addedWord,
31+ extensionPath,
32+ marketplacePath,
33+} from "../lib/marketplace";
1934 import { cn } from "../lib/cn";
2035
2136 const 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";
2439 quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`,
2540 };
2641
27−/** Who stands behind a listing: a small badge, with the sentence on hover. */
28−export function TierBadge({ tier }: { tier: ListingTier }) {
42+/** Each tier's colour: Official the accent, Verified green, Community amber, Internal blue. */
43+const TIER_TONE: Record<ListingTier, string> = {
44+ official: "bg-accent/12 text-accent",
45+ verified: "bg-success/12 text-success",
46+ community: "bg-warn/12 text-warn",
47+ internal: "bg-info/12 text-info",
48+};
49+
50+/**
51+ * Who stands behind a listing: a small square badge in its tier's colour,
52+ * with the sentence on hover or focus. Inside a link, `focusable={false}`
53+ * keeps it from being a second stop for the keyboard.
54+ */
55+export function TierBadge({ tier, focusable = true, className }: { tier: ListingTier; focusable?: boolean; className?: string }) {
56+ return (
57+ <Hint label={`${TIERS[tier].label}: ${TIERS[tier].about}`}>
58+ <span
59+ tabIndex={focusable ? 0 : undefined}
60+ className={cn(
61+ "inline-flex shrink-0 items-center rounded-[5px] px-1.5 py-px font-mono text-[0.625rem] leading-4 font-medium tracking-[0.08em] whitespace-nowrap uppercase outline-none focus-visible:ring-2 focus-visible:ring-accent",
62+ TIER_TONE[tier],
63+ className,
64+ )}
65+ >
66+ {TIERS[tier].label}
67+ </span>
68+ </Hint>
69+ );
70+}
71+
72+/**
73+ * Whether a listing can be added here: Available, Connected or Installed,
74+ * Soon, or Not available here, as an icon and a word (never a button),
75+ * with what it means, or why, on hover or focus.
76+ */
77+export function AvailabilityBadge({
78+ availability,
79+ kind,
80+ why,
81+ off = false,
82+ hint,
83+ focusable = true,
84+ className,
85+}: {
86+ availability: Availability;
87+ kind: ListingKind;
88+ /** Why it isn't available here. */
89+ why?: string | null;
90+ /** An installed extension that was switched off. */
91+ off?: boolean;
92+ /** A hint in place of the usual sentence. */
93+ hint?: string;
94+ /** False inside a link, so it isn't a second stop for the keyboard. */
95+ focusable?: boolean;
96+ className?: string;
97+}) {
98+ const label = availability === "added" ? (off ? "Switched off" : addedWord(kind)) : AVAILABILITY[availability].label;
99+ const about =
100+ hint ??
101+ (availability === "unavailable" && why
102+ ? why
103+ : availability === "added"
104+ ? off
105+ ? "Installed, and switched off: its token doesn't work and its page doesn't load until an owner switches it on."
106+ : `This workspace has it ${kind === "integration" ? "connected" : "installed"}.`
107+ : AVAILABILITY[availability].about);
108+ const icon =
109+ availability === "available" ? (
110+ <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-success" />
111+ ) : availability === "added" ? (
112+ off ? (
113+ <Power size={12} className="shrink-0" />
114+ ) : (
115+ <Check size={13} className="shrink-0" />
116+ )
117+ ) : availability === "soon" ? (
118+ <Clock size={12} className="shrink-0" />
119+ ) : (
120+ <Ban size={12} className="shrink-0" />
121+ );
29122 return (
30− <Hint label={TIERS[tier].about}>
31− <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
32− <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge>
123+ <Hint label={about}>
124+ <span
125+ tabIndex={focusable ? 0 : undefined}
126+ className={cn(
127+ "inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded text-xs whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-accent",
128+ availability === "available" ? "text-fg-soft" : availability === "added" ? (off ? "text-warn" : "text-success") : "text-muted",
129+ className,
130+ )}
131+ >
132+ {icon}
133+ <span className="truncate">{label}</span>
33134 </span>
34135 </Hint>
35136 );
37138
38139 /** "Soon", on what is planned and not built. */
39140 export function ComingBadge() {
40− return <Badge tone="neutral">Soon</Badge>;
141+ return <AvailabilityBadge availability="soon" kind="extension" />;
41142 }
42143
144+/**
145+ * What the labels mean: who builds what (the four tiers) and whether a
146+ * listing can be added here. On Discover, and at the end of each tab.
147+ */
148+export function ListingLegend({ id = "legend", kind }: { id?: string; kind?: ListingKind }) {
149+ return (
150+ <section aria-labelledby={id} className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
151+ <h2 id={id} className="text-base font-semibold tracking-tight">
152+ Who builds what
153+ </h2>
154+ <p className="mt-1 max-w-2xl text-sm text-muted">Every listing says who stands behind it, and whether it can be added here. Today, everything listed is g1t's own.</p>
155+ <ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
156+ {LISTING_TIERS.map((tier) => (
157+ <li key={tier} className="rounded-xl border border-line bg-bg p-4">
158+ <TierBadge tier={tier} />
159+ <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p>
160+ </li>
161+ ))}
162+ </ul>
163+ <h3 className="mt-6 text-sm font-semibold">Whether you can add it</h3>
164+ <ul className="mt-3 grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
165+ {AVAILABILITIES.map((availability) => (
166+ <li key={availability} className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[0.8125rem] text-muted">
167+ <span className="w-36 shrink-0">
168+ <AvailabilityBadge availability={availability} kind={kind ?? "integration"} />
169+ </span>
170+ <span className="min-w-0 basis-56 grow">
171+ {availability === "added"
172+ ? kind === "extension"
173+ ? "Installed in this workspace."
174+ : kind === "integration"
175+ ? "Connected for this workspace."
176+ : "Connected or installed in this workspace."
177+ : availability === "unavailable"
178+ ? "This workspace or this g1t lacks something it needs, and the listing says what."
179+ : AVAILABILITY[availability].about}
180+ </span>
181+ </li>
182+ ))}
183+ </ul>
184+ </section>
185+ );
186+}
187+
188+/** A tier's section while nothing in it is listed: said plainly, so the structure shows. */
189+export function TierEmpty({ tier, kind, filtered = false }: { tier: ListingTier; kind: ListingKind; filtered?: boolean }) {
190+ return (
191+ <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">
192+ <span>{filtered && tier === "official" ? TIERS.official.none[kind] : filtered ? `None match. ${TIERS[tier].none[kind]}` : TIERS[tier].none[kind]}</span>
193+ </p>
194+ );
195+}
196+
197+/**
198+ * Filters by tier and by availability, kept in the address
199+ * (`?tier=verified&availability=soon`) so a filtered page can be shared.
200+ * Each option shows how many listings it has before filtering.
201+ */
202+export function ListingFilterBar({
203+ kind,
204+ filters,
205+ counts,
206+}: {
207+ kind: ListingKind;
208+ filters: ListingFilters;
209+ counts: { tier: Record<ListingTier, number>; availability: Record<Availability, number>; all: number };
210+}) {
211+ const [params] = useSearchParams();
212+ const { pathname } = useLocation();
213+ const href = (key: "tier" | "availability", value: string) => {
214+ const next = new URLSearchParams(params);
215+ if (value === "all") next.delete(key);
216+ else next.set(key, value);
217+ const query = next.toString();
218+ return query ? `${pathname}?${query}` : pathname;
219+ };
220+ const Option = ({ group, value, current, count, children }: { group: "tier" | "availability"; value: string; current: string; count: number; children: ReactNode }) => (
221+ <Link
222+ to={href(group, value)}
223+ replace
224+ preventScrollReset
225+ aria-current={current === value ? "true" : undefined}
226+ className={cn(
227+ "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent",
228+ current === value ? "border-line-strong bg-raised font-medium text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg",
229+ )}
230+ >
231+ {children}
232+ <span className={cn("tabular-nums", current === value ? "text-muted" : "text-faint")}>{count}</span>
233+ </Link>
234+ );
235+ return (
236+ <div className="grid gap-2.5" role="group" aria-label={`Filter ${kind === "extension" ? "extensions" : "integrations"}`}>
237+ <div className="flex flex-wrap items-center gap-1.5">
238+ <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Who builds it</span>
239+ <Option group="tier" value="all" current={filters.tier} count={counts.all}>
240+ All
241+ </Option>
242+ {LISTING_TIERS.map((tier) => (
243+ <Option key={tier} group="tier" value={tier} current={filters.tier} count={counts.tier[tier]}>
244+ <span aria-hidden="true" className={cn("size-1.5 rounded-full", TIER_DOT[tier])} />
245+ {TIERS[tier].label}
246+ </Option>
247+ ))}
248+ </div>
249+ <div className="flex flex-wrap items-center gap-1.5">
250+ <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Availability</span>
251+ <Option group="availability" value="all" current={filters.availability} count={counts.all}>
252+ All
253+ </Option>
254+ {AVAILABILITIES.map((availability) => (
255+ <Option key={availability} group="availability" value={availability} current={filters.availability} count={counts.availability[availability]}>
256+ {availability === "added" ? addedWord(kind) : AVAILABILITY[availability].label}
257+ </Option>
258+ ))}
259+ </div>
260+ </div>
261+ );
262+}
263+
264+const TIER_DOT: Record<ListingTier, string> = { official: "bg-accent", verified: "bg-success", community: "bg-warn", internal: "bg-info" };
265+
266+/** How many listings each filter option has. */
267+export function filterCounts(listings: { tier: ListingTier; availability: Availability }[]) {
268+ const tier = Object.fromEntries(LISTING_TIERS.map((t) => [t, listings.filter((l) => l.tier === t).length])) as Record<ListingTier, number>;
269+ const availability = Object.fromEntries(AVAILABILITIES.map((a) => [a, listings.filter((l) => l.availability === a).length])) as Record<Availability, number>;
270+ return { tier, availability, all: listings.length };
271+}
272+
273+/**
274+ * A listing page's header line: its tier, who publishes it, whether it can
275+ * be added here (`children`), and its category, each labelled.
276+ */
277+export function ListingFacts({ tier, publisher, category, children }: { tier: ListingTier; publisher: string; category: string; children: ReactNode }) {
278+ const Dot = () => (
279+ <span aria-hidden="true" className="text-faint">
280+ ·
281+ </span>
282+ );
283+ return (
284+ <p className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm text-muted">
285+ <TierBadge tier={tier} />
286+ <span>
287+ by <span className="text-fg-soft">{publisher}</span>
288+ </span>
289+ <span className="inline-flex items-center gap-2.5">
290+ <Dot />
291+ {children}
292+ </span>
293+ <span className="inline-flex items-center gap-2.5">
294+ <Dot />
295+ {category}
296+ </span>
297+ </p>
298+ );
299+}
300+
43301 /** A section's heading, with a count or a link at its end. */
44−export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) {
302+export function SectionHead({ id, title, aside, sub = false, children }: { id: string; title: ReactNode; aside?: ReactNode; sub?: boolean; children?: ReactNode }) {
303+ const Heading = sub ? "h3" : "h2";
45304 return (
46305 <div className="mb-3">
47306 <div className="flex items-baseline justify-between gap-3">
48− <h2 id={id} className="text-base font-semibold tracking-tight">
307+ <Heading id={id} className={cn("flex items-center gap-2 font-semibold tracking-tight", sub ? "text-sm" : "text-base")}>
49308 {title}
50− </h2>
309+ </Heading>
51310 {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
52311 </div>
53312 {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
65324 );
66325 }
67326
327+/** What a request's dialog shows of the listing asked for: its mark, tier, publisher and availability. */
328+export type RequestedListing = { kind: ListingKind; tier: ListingTier; publisher: string; mark: ReactNode };
329+
68330 /**
69− * Asking the workspace's owners to add something: a dialog with an
70− * optional note, sent to the requests route. Once sent, it says so.
331+ * Asking the workspace's owners to add something: a dialog that says
332+ * exactly what is asked for (who builds it, and that it can be added),
333+ * with an optional note, sent to the requests route. Once sent, it says so.
71334 */
72−export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) {
335+export function RequestButton({
336+ slug,
337+ listing,
338+ name,
339+ requested,
340+ about,
341+ className,
342+}: {
343+ slug: string;
344+ listing: string;
345+ name: string;
346+ requested: boolean;
347+ about?: RequestedListing;
348+ className?: string;
349+}) {
73350 const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
74351 const [open, setOpen] = useState(false);
75352 const sent = fetcher.state === "idle" && fetcher.data?.error === null;
100377 <DialogTitle>Ask to add {name}</DialogTitle>
101378 <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
102379 </DialogHeader>
380+ {about && (
381+ <div className="flex items-center gap-3 rounded-lg border border-line bg-bg px-3 py-2.5">
382+ {about.mark}
383+ <div className="min-w-0">
384+ <p className="truncate text-sm font-medium">{name}</p>
385+ <p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
386+ <TierBadge tier={about.tier} />
387+ <span>by {about.publisher}</span>
388+ <span aria-hidden="true" className="text-faint">
389+ ·
390+ </span>
391+ <AvailabilityBadge availability="available" kind={about.kind} hint="An owner can add it as soon as they agree." />
392+ </p>
393+ </div>
394+ </div>
395+ )}
103396 <input type="hidden" name="intent" value="request" />
104397 <input type="hidden" name="listing" value={listing} />
105398 <label className="grid gap-1.5 text-sm">
131424 );
132425 }
133426
134−/** What an owner or a member can do with an integration. */
135−export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
427+/**
428+ * What an owner or a member can do with an integration: connect it, ask
429+ * for it, or manage it once connected; nothing while it is Soon or not
430+ * available here, where the availability says why instead.
431+ */
432+export function IntegrationAction({ listing, slug, owner, className }: { listing: IntegrationListing; slug: string; owner: boolean; className?: string }) {
433+ if (listing.availability === "soon" || listing.availability === "unavailable") return null;
434+ if (listing.scope === "personal") {
435+ return listing.href ? (
436+ <Link to={listing.href} className={cn(ACTION.quiet, className)}>
437+ Connect yours
438+ </Link>
439+ ) : null;
440+ }
136441 if (listing.connected) {
137442 return listing.href ? (
138− <Link to={listing.href} className={ACTION.quiet}>
443+ <Link to={listing.href} className={cn(ACTION.quiet, className)}>
139444 Manage
140445 </Link>
141446 ) : null;
142447 }
143448 if (owner) {
144449 return listing.href ? (
145− <Link to={listing.href} className={ACTION.primary}>
450+ <Link to={listing.href} className={cn(ACTION.primary, className)}>
146451 <Plus size={14} />
147452 Connect
148453 </Link>
149454 ) : null;
150455 }
151− return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />;
456+ return (
457+ <RequestButton
458+ slug={slug}
459+ listing={listing.ref}
460+ name={listing.view.name}
461+ requested={listing.requested}
462+ about={{ kind: "integration", tier: listing.tier, publisher: listing.publisher, mark: <ConnectorMark view={listing.view} size={32} /> }}
463+ className={className}
464+ />
465+ );
466+}
467+
468+/** Whether a listing can't be added by anyone here: drawn muted and dashed, with nothing to press. */
469+function inert(availability: Availability): boolean {
470+ return availability === "soon" || availability === "unavailable";
152471 }
153472
154−/** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */
473+/** The card every listing shares: a link to its page over the whole card, with its badges and actions above it. */
474+function ListingShell({ to, name, muted, children }: { to: string; name: string; muted: boolean; children: ReactNode }) {
475+ return (
476+ <article
477+ className={cn(
478+ "relative flex flex-col rounded-xl border p-4 transition-colors",
479+ muted ? "border-dashed border-line-strong/70 bg-transparent hover:border-line-strong" : "border-line bg-surface hover:border-line-strong",
480+ )}
481+ >
482+ <Link to={to} prefetch="intent" aria-label={name} className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-accent" />
483+ {children}
484+ </article>
485+ );
486+}
487+
488+/**
489+ * An integration as a card: its mark, who builds it, what it does for
490+ * agents, whether the workspace can add it or has it, and the way to.
491+ */
155492 export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
156493 const { view, connected } = listing;
494+ const muted = inert(listing.availability);
157495 return (
158− <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
496+ <ListingShell to={listing.path} name={view.name} muted={muted}>
159497 <div className="flex items-start gap-3">
160− <ConnectorMark view={view} size={40} />
498+ <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
499+ <ConnectorMark view={view} size={40} />
500+ </span>
161501 <div className="min-w-0 grow">
162− <h3 className="truncate text-sm font-semibold">{view.name}</h3>
502+ <div className="flex min-w-0 items-center justify-between gap-2">
503+ <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{view.name}</h3>
504+ <span className="relative shrink-0">
505+ <TierBadge tier={listing.tier} />
506+ </span>
507+ </div>
163508 <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
164509 </div>
165510 </div>
166511 {view.capabilities.length > 0 && (
167− <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does">
512+ <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label={muted ? "What it will do" : "What it does"}>
168513 {view.capabilities.map((capability) => (
169− <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted">
514+ <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}>
170515 {capability}
171516 </li>
172517 ))}
173518 </ul>
174519 )}
175− <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
520+ <div className="relative mt-4 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3">
176521 <span className="flex min-w-0 items-center gap-1.5 text-xs">
177− {connected ? (
178− connected.problem ? (
179− <Hint label={connected.problem}>
180− <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none">
181− <CircleAlert size={13} className="shrink-0" />
182− <span className="truncate">Needs attention</span>
183− </span>
184− </Hint>
185− ) : (
186− <span className="inline-flex min-w-0 items-center gap-1 text-success">
187− <Check size={13} className="shrink-0" />
188− <span className="truncate">Connected</span>
522+ {connected?.problem ? (
523+ <Hint label={connected.problem}>
524+ <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent">
525+ <CircleAlert size={13} className="shrink-0" />
526+ <span className="truncate">Needs attention</span>
189527 </span>
190− )
528+ </Hint>
191529 ) : (
192− <span className="truncate text-faint">Not connected</span>
530+ <AvailabilityBadge
531+ availability={listing.availability}
532+ kind="integration"
533+ why={listing.why}
534+ hint={
535+ listing.scope === "personal" && listing.availability === "available"
536+ ? "Each person connects their own. Agents use it only when that person asks."
537+ : listing.availability === "available" && !owner
538+ ? "An owner connects it for everyone. Ask one with Request."
539+ : undefined
540+ }
541+ />
193542 )}
543+ {listing.scope === "personal" && <span className="truncate text-faint">· Each person's own</span>}
194544 {owner && listing.waiting > 0 && (
195545 <>
196546 <span className="text-faint">·</span>
200550 </span>
201551 <IntegrationAction listing={listing} slug={slug} owner={owner} />
202552 </div>
203− </article>
553+ </ListingShell>
554+ );
555+}
556+
557+/**
558+ * An integration as a compact row, for the long list of coming ones:
559+ * muted and dashed, its tier and Soon, and nothing to press but its page.
560+ */
561+export function IntegrationRow({ listing }: { listing: IntegrationListing }) {
562+ const { view } = listing;
563+ return (
564+ <li className="relative flex items-center gap-3 rounded-lg border border-dashed border-line-strong/70 px-3 py-2.5 transition-colors hover:border-line-strong">
565+ <Link to={listing.path} prefetch="intent" aria-label={view.name} className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-accent" />
566+ <span className="shrink-0 opacity-60 grayscale">
567+ <ConnectorMark view={view} size={28} />
568+ </span>
569+ <span className="min-w-0 grow">
570+ <span className="block truncate text-sm text-fg-soft">{view.name}</span>
571+ <span className="block truncate text-xs text-faint">{view.description}</span>
572+ </span>
573+ <span className="relative flex shrink-0 flex-col items-end gap-1">
574+ <TierBadge tier={listing.tier} />
575+ <AvailabilityBadge availability={listing.availability} kind="integration" why={listing.why} />
576+ </span>
577+ </li>
204578 );
205579 }
206580
209583 return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
210584 }
211585
212−/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */
586+/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before, or once installed. */
213587 export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
214588 const fetcher = useFetcher<{ error: string | null }>();
215− const { manifest, install } = listing;
216− if (manifest.status !== "available") {
589+ const { manifest } = listing;
590+ if (listing.availability !== "available") return null;
591+ if (!owner) {
217592 return (
218− <Hint label="Not published yet. It can be installed from its first release.">
219− <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)}>
220− Soon
221− </span>
222− </Hint>
593+ <RequestButton
594+ slug={slug}
595+ listing={listing.ref}
596+ name={manifest.name}
597+ requested={listing.requested}
598+ about={{ kind: "extension", tier: listing.tier, publisher: manifest.publisher.name, mark: <ExtensionMark manifest={manifest} size={32} /> }}
599+ className={className}
600+ />
223601 );
224602 }
225− if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>;
226− if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />;
227603 return (
228604 <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
229605 <input type="hidden" name="intent" value="install" />
236612 );
237613 }
238614
615+/** An extension's availability, as its card and page show it. */
616+export function ExtensionAvailability({ listing, owner }: { listing: ExtensionListing; owner: boolean }) {
617+ return (
618+ <AvailabilityBadge
619+ availability={listing.availability}
620+ kind="extension"
621+ off={listing.install ? !listing.install.enabled : false}
622+ hint={
623+ listing.availability === "soon"
624+ ? "Not published yet. It can be installed from its first release."
625+ : listing.availability === "available" && !owner
626+ ? "An owner installs it for everyone. Ask one with Request."
627+ : listing.install?.enabled
628+ ? `Version ${listing.install.version} is installed.`
629+ : undefined
630+ }
631+ />
632+ );
633+}
634+
239635 /** Where an extension's data goes, in a few words for a card. */
240636 function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
241637 // "the CRM you connect" reads, on a card, as "Works with your CRM".
243639 return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`;
244640 }
245641
246−/** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */
642+/** An extension as a card: its mark, publisher and tier, what it does, where its data goes, and whether it can be added. */
247643 export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
248644 const { manifest } = listing;
645+ const muted = inert(listing.availability);
249646 return (
250− <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
647+ <ListingShell to={extensionPath(slug, manifest.id)} name={manifest.name} muted={muted}>
251648 <div className="flex items-start gap-3">
252− <ExtensionMark manifest={manifest} />
649+ <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
650+ <ExtensionMark manifest={manifest} />
651+ </span>
253652 <div className="min-w-0 grow">
254− <h3 className="truncate text-sm font-semibold">
255− <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">
256− {manifest.name}
257− </Link>
258− </h3>
653+ <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{manifest.name}</h3>
259654 <p className="truncate text-xs text-muted">
260− {manifest.publisher.name} · {manifest.category}
655+ by {manifest.publisher.name} · {manifest.category}
261656 </p>
262657 </div>
263− <span className="relative">
264− <TierBadge tier={manifest.publisher.tier} />
658+ <span className="relative shrink-0">
659+ <TierBadge tier={listing.tier} />
265660 </span>
266661 </div>
267662 <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
268− <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
269− <span className="min-w-0 truncate text-xs text-faint">{dataLine(manifest)}</span>
663+ <p className="mt-2 truncate text-xs text-faint">{dataLine(manifest)}</p>
664+ <div className="relative mt-3 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3">
665+ <span className="flex min-w-0 items-center gap-1.5 text-xs">
666+ <ExtensionAvailability listing={listing} owner={owner} />
667+ {owner && listing.waiting > 0 && (
668+ <>
669+ <span className="text-faint">·</span>
670+ <Waiting slug={slug} count={listing.waiting} />
671+ </>
672+ )}
673+ </span>
270674 <ExtensionAction listing={listing} slug={slug} owner={owner} />
271675 </div>
272− </article>
676+ </ListingShell>
273677 );
274678 }
275679
338742
339743 /**
340744 * A starter kit as a card: the extensions it brings, overlapped, its name
341− * and what it is for. Installed together once every one is published;
342− * until then it says Soon.
745+ * and what it is for, who builds them, and whether it can be added.
746+ * Installed together once every one is published; until then it is Soon,
747+ * drawn muted with nothing to press.
343748 */
344749 export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) {
345750 const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available");
751+ const tiers = [...new Set(extensions.map((extension) => extension.publisher.tier))];
346752 return (
347− <article className="flex flex-col rounded-xl border border-line bg-surface p-4">
348− <div className="flex items-center">
349− {extensions.map((extension, index) => (
350− <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-surface", index > 0 && "-ml-2")}>
351− <ExtensionMark manifest={extension} size={30} />
352− </span>
353− ))}
354− </div>
355− <h3 className="mt-3 text-sm font-semibold">{kit.name}</h3>
356− <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p>
357− <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
358− <p className="min-w-0 truncate text-xs text-faint">
753+ <article className={cn("flex flex-col rounded-xl border p-4", ready ? "border-line bg-surface" : "border-dashed border-line-strong/70")}>
754+ <div className="flex items-center justify-between gap-3">
755+ <div className={cn("flex items-center", !ready && "opacity-60 grayscale")}>
359756 {extensions.map((extension, index) => (
360− <span key={extension.id}>
361− {index > 0 && ", "}
362− <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline">
363− {extension.name}
364− </Link>
757+ <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-bg", index > 0 && "-ml-2")}>
758+ <ExtensionMark manifest={extension} size={30} />
365759 </span>
366760 ))}
367− </p>
368− {!ready && (
369− <Hint label="Its extensions aren't all published yet. The kit installs them together once they are.">
370− <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent")}>
371− Soon
372− </span>
373− </Hint>
374− )}
761+ </div>
762+ <span className="flex gap-1">
763+ {tiers.map((tier) => (
764+ <TierBadge key={tier} tier={tier} />
765+ ))}
766+ </span>
767+ </div>
768+ <h3 className={cn("mt-3 text-sm font-semibold", !ready && "text-fg-soft")}>{kit.name}</h3>
769+ <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p>
770+ <p className="mt-2 truncate text-xs text-faint">
771+ {extensions.map((extension, index) => (
772+ <span key={extension.id}>
773+ {index > 0 && ", "}
774+ <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline">
775+ {extension.name}
776+ </Link>
777+ </span>
778+ ))}
779+ </p>
780+ <div className="mt-3 flex min-h-8 items-center border-t border-line pt-3">
781+ <AvailabilityBadge
782+ availability={ready ? "available" : "soon"}
783+ kind="extension"
784+ hint={ready ? undefined : "Its extensions aren't all published yet. The kit installs them together once they are."}
785+ />
375786 </div>
376787 </article>
377788 );
+9−2
2121 * with the account carry over from it until the first change.
2222 * No Workers or React imports, so it can be tested under Node.
2323 */
24−import type { ExtensionInstall } from "@g1t/contracts";
24+import type { ExtensionInstall, ListingTier } from "@g1t/contracts";
2525 import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors";
26−import { extensionById } from "@g1t/contracts/marketplace";
26+import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace";
2727
2828 /** The apps that are always in the dock. */
2929 export type BuiltinApp = "today" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace";
6767 about: string;
6868 /** The Marketplace listing it was installed from: `integration:sentry`. */
6969 listing: string;
70+ /** Who stands behind it, as its Marketplace listing says: its publisher and tier. */
71+ publisher: string;
72+ tier: ListingTier;
7073 /** The connector behind it, for its mark. */
7174 connector: Pick<Connector, "id" | "name" | "provider">;
7275 /** Its page in the workspace `slug`. */
111114 name: extension.name,
112115 about: extension.tagline,
113116 listing: `extension:${extension.id}`,
117+ publisher: extension.publisher.name,
118+ tier: extension.publisher.tier,
114119 connector: { id: extension.id, name: extension.name, provider: undefined },
115120 // Its own page, in a sandboxed frame, is the extension host's to serve; until then, its listing.
116121 path: (slug) => `/${slug}/-/marketplace/extensions/${extension.id}`,
125130 name: connector.name,
126131 about: connector.description,
127132 listing: `integration:${connector.id}`,
133+ publisher: CONNECTOR_PUBLISHER.name,
134+ tier: CONNECTOR_PUBLISHER.tier,
128135 connector: { id: connector.id, name: connector.name, provider: connector.provider },
129136 path: integrationPath(connector),
130137 usable: true,
+12−1
1616
1717 /** What the workspace `slug` has connected, as `viewer` may see it. Each source that does not answer counts as nothing connected. */
1818 export async function connectedStates(slug: string, viewer: User): Promise<Record<string, ConnectedState>> {
19+ return (await workspaceConnections(slug, viewer)).connected;
20+}
21+
22+/**
23+ * What the workspace `slug` has connected, and the connectors this g1t
24+ * can't connect at all, by id, each with why: GitHub when no GitHub App is
25+ * set up (as on a self-hosted g1t that hasn't added one).
26+ */
27+export async function workspaceConnections(slug: string, viewer: User): Promise<{ connected: Record<string, ConnectedState>; unavailable: Record<string, string> }> {
1928 const [connections, github, hooks] = await Promise.all([
2029 integrations.list(slug, viewer).catch(() => null),
2130 githubApp.status(viewer, slug).catch(() => null),
4756 manage: null,
4857 };
4958 }
50− return connected;
59+ const unavailable: Record<string, string> = {};
60+ if (github?.ok && !github.value.configured) unavailable.github = "This g1t has no GitHub App set up. Whoever runs it adds one first.";
61+ return { connected, unavailable };
5162 }
+5−4
11 /**
22 * What the Marketplace's pages read besides the layout's: what the
3− * workspace has connected (lib/connected.server.ts).
3+ * workspace has connected, and what this g1t can't connect, with why
4+ * (lib/connected.server.ts).
45 */
56 import type { User } from "@g1t/contracts";
67
78 import type { ConnectedState } from "./connectors";
8−import { connectedStates } from "./connected.server";
9+import { workspaceConnections } from "./connected.server";
910
10−export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> {
11− return connectedStates(slug, viewer);
11+export async function loadConnected(slug: string, viewer: User): Promise<{ connected: Record<string, ConnectedState>; unavailable: Record<string, string> }> {
12+ return workspaceConnections(slug, viewer);
1213 }
+60−2
22 import { test } from "node:test";
33
44 import type { InstallRequest } from "@g1t/contracts";
5−import { connectorsFor } from "@g1t/contracts/connectors";
5+import { connectorById, connectorsFor } from "@g1t/contracts/connectors";
66
77 import { FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace";
88
9−import { STARTER_KITS, addPath, comingIntegrations, extensionListings, integrationListings, integrationMatches, isConnectedSystem, marketplaceForm } from "./marketplace.ts";
9+import {
10+ STARTER_KITS,
11+ TIERS,
12+ addPath,
13+ categoryTitle,
14+ comingIntegrations,
15+ comingListings,
16+ extensionListings,
17+ integrationListings,
18+ integrationMatches,
19+ integrationUses,
20+ isConnectedSystem,
21+ marketplaceForm,
22+ passes,
23+ readFilters,
24+ tiersShown,
25+} from "./marketplace.ts";
1026 import { modeOf } from "./workspace-nav.ts";
1127
1228 const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({
101117 assert.ok(FIRST_PARTY_EXTENSIONS.filter(isConnectedSystem).every((m) => m.bridges), "a connected system says what it bridges");
102118 });
103119
120+test("every listing says who builds it and whether it can be added here", () => {
121+ const views = connectorsFor("workspace");
122+ const connected = { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } };
123+ const listings = integrationListings(views, connected, [], "ana", "acme", { github: "This g1t has no GitHub App set up." });
124+ assert.ok(listings.every((l) => l.tier === "official" && l.publisher === "g1t"), "the connector catalog is g1t's own");
125+ assert.equal(listings.find((l) => l.view.id === "sentry")!.availability, "added");
126+ assert.equal(listings.find((l) => l.view.id === "jira")!.availability, "available");
127+ const github = listings.find((l) => l.view.id === "github")!;
128+ assert.equal(github.availability, "unavailable");
129+ assert.equal(github.why, "This g1t has no GitHub App set up.");
130+ assert.equal(github.href, null, "nothing to connect where it can't be connected");
131+ assert.equal(listings.find((l) => l.view.id === "linear")!.path, "/acme/-/marketplace/integrations/linear");
132+
133+ const coming = comingListings(views, connectorsFor("personal").filter((v) => v.id === "gmail"), "acme");
134+ assert.ok(coming.every((l) => l.availability === "soon" && l.href === null && l.tier === "official"));
135+ assert.equal(coming.find((l) => l.view.id === "gmail")!.scope, "personal");
136+
137+ const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, [], [], "ana");
138+ assert.ok(extensions.every((l) => l.tier === "official" && l.availability === "soon"), "g1t's own, none published yet");
139+});
140+
141+test("an integration's page says what it does today and what is still Soon", () => {
142+ const linear = integrationUses(connectorById("linear")!);
143+ assert.deepEqual(linear.today.map((u) => u.scope), ["workspace"]);
144+ assert.deepEqual(linear.today[0]!.capabilities, ["Agents can read", "Writes back"]);
145+ assert.deepEqual(linear.soon.map((u) => u.scope), ["personal"], "each person's Linear inbox is Soon");
146+ const slack = integrationUses(connectorById("slack")!);
147+ assert.equal(slack.today.length, 0);
148+ assert.equal(slack.soon.length, 2);
149+ assert.equal(categoryTitle("issues"), "Issues & projects");
150+});
151+
152+test("filters by tier and availability are read from the address, and unknown ones are All", () => {
153+ assert.deepEqual(readFilters(new URLSearchParams("tier=verified&availability=soon")), { tier: "verified", availability: "soon" });
154+ assert.deepEqual(readFilters(new URLSearchParams("tier=gold&availability=maybe")), { tier: "all", availability: "all" });
155+ assert.deepEqual(tiersShown({ tier: "all", availability: "all" }), ["official", "verified", "community", "internal"]);
156+ assert.deepEqual(tiersShown({ tier: "community", availability: "all" }), ["community"]);
157+ assert.ok(passes({ tier: "official", availability: "soon" }, { tier: "all", availability: "soon" }));
158+ assert.ok(!passes({ tier: "official", availability: "soon" }, { tier: "verified", availability: "all" }));
159+ for (const tier of ["verified", "community", "internal"] as const) assert.ok(TIERS[tier].none.extension.length > 0, `${tier} says when it is empty`);
160+});
161+
104162 test("the Marketplace is part of Apps, not a mode of its own", () => {
105163 assert.equal(modeOf("/acme/-/marketplace", "acme"), "apps");
106164 assert.equal(modeOf("/acme/-/marketplace/extensions/mail", "acme"), "apps");
+181−28
1111 *
1212 * No Workers or React imports, so it can be tested under Node.
1313 */
14−import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier } from "@g1t/contracts";
15−import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
16−import { listingRef, parseListing } from "@g1t/contracts/marketplace";
14+import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingKind, ListingTier } from "@g1t/contracts";
15+import { CONNECTOR_CATEGORIES, type Connector, type ConnectorCapability, type ConnectorScope, type ConnectorView, connectorPath, connectorView } from "@g1t/contracts/connectors";
16+import { CONNECTOR_PUBLISHER, LISTING_TIERS, listingRef, parseListing } from "@g1t/contracts/marketplace";
1717
1818 import type { ConnectedState } from "./connectors";
1919
2222 return `/${slug}/-/marketplace${page ? `/${page}` : ""}`;
2323 }
2424
25−/** Who stands behind a listing, in a word and a sentence. */
26−export const TIERS: Record<ListingTier, { label: string; about: string }> = {
27− official: { label: "Official", about: "Built and supported by g1t." },
28− verified: { label: "Verified", about: "From a reviewed publisher, whose code and permissions are checked before it is listed." },
29− community: { label: "Community", about: "From anyone, shared from a public repository. Pages run sandboxed; server code waits for an isolated sandbox." },
30− internal: { label: "Internal", about: "Built by your own people and agents, and seen only by your workspace." },
25+/**
26+ * Who stands behind a listing: in a word, a sentence, and what a tier's
27+ * section says while nothing in it is listed (`none`, by kind).
28+ */
29+export const TIERS: Record<ListingTier, { label: string; about: string; none: Record<ListingKind, string> }> = {
30+ official: {
31+ label: "Official",
32+ about: "Built and supported by g1t.",
33+ none: { extension: "No official extensions match.", integration: "No official integrations match." },
34+ },
35+ verified: {
36+ label: "Verified",
37+ about: "From a reviewed publisher. Its code and the scopes it asks for are checked before it is listed.",
38+ none: { extension: "No verified publishers yet.", integration: "No verified publishers yet." },
39+ },
40+ community: {
41+ label: "Community",
42+ about: "From anyone. Its pages run sandboxed on the user-content domain, and its server code stays off until that sandbox is hardened.",
43+ none: { extension: "No community extensions yet.", integration: "No community integrations yet." },
44+ },
45+ internal: {
46+ label: "Internal",
47+ about: "Built by your own people and agents, and promoted for this workspace only.",
48+ none: { extension: "Nothing built in this workspace yet.", integration: "Nothing built in this workspace yet." },
49+ },
3150 };
3251
52+/**
53+ * Whether a listing can be added here, now: it can (`available`), the
54+ * workspace has it (`added`: connected, or installed), it is planned and
55+ * can't be added by anyone yet (`soon`), or this workspace or this g1t
56+ * lacks something it needs (`unavailable`, with why).
57+ */
58+export type Availability = "available" | "added" | "soon" | "unavailable";
59+
60+export const AVAILABILITIES: readonly Availability[] = ["available", "added", "soon", "unavailable"];
61+
62+/** Each availability in words, and the sentence its hint shows. */
63+export const AVAILABILITY: Record<Availability, { label: string; about: string }> = {
64+ available: { label: "Available", about: "Can be added now. Owners add it for everyone; anyone else can ask an owner." },
65+ added: { label: "Added", about: "This workspace has it." },
66+ soon: { label: "Soon", about: "Planned, not built yet. Nobody can add it until it is released." },
67+ unavailable: { label: "Not available here", about: "This workspace or this g1t lacks something it needs." },
68+};
69+
70+/** What a workspace that has a listing calls it: an integration is connected, an extension installed. */
71+export function addedWord(kind: ListingKind): string {
72+ return kind === "integration" ? "Connected" : "Installed";
73+}
74+
75+/** A filter's value: one tier or availability, or every one. */
76+export type ListingFilters = { tier: ListingTier | "all"; availability: Availability | "all" };
77+
78+/** The filters a Marketplace page's address asks for (`?tier=verified&availability=soon`); anything unknown is All. */
79+export function readFilters(params: URLSearchParams): ListingFilters {
80+ const tier = params.get("tier");
81+ const availability = params.get("availability");
82+ return {
83+ tier: LISTING_TIERS.includes(tier as ListingTier) ? (tier as ListingTier) : "all",
84+ availability: AVAILABILITIES.includes(availability as Availability) ? (availability as Availability) : "all",
85+ };
86+}
87+
88+/** Whether a listing passes the filters. */
89+export function passes(listing: { tier: ListingTier; availability: Availability }, filters: ListingFilters): boolean {
90+ return (filters.tier === "all" || listing.tier === filters.tier) && (filters.availability === "all" || listing.availability === filters.availability);
91+}
92+
93+/** The tiers a page shows sections for under `filters`: every one, or the one asked for. */
94+export function tiersShown(filters: ListingFilters): readonly ListingTier[] {
95+ return filters.tier === "all" ? LISTING_TIERS : [filters.tier];
96+}
97+
3398 /** Whether `request` is `username`'s and still waiting. */
3499 function openFor(request: InstallRequest, ref: string, username: string): boolean {
35100 return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase();
40105 return requests.filter((request) => request.status === "open" && request.listing === ref).length;
41106 }
42107
43−/** An integration a workspace can connect, with whether it has. */
108+/** An integration in the Marketplace, with who stands behind it and whether the workspace has it. */
44109 export type IntegrationListing = {
45110 ref: string;
46111 view: ConnectorView;
112+ /** Who connects it: an owner, once for the workspace, or each person for themselves. */
113+ scope: ConnectorScope;
114+ /** Every connector is g1t's own (CONNECTOR_PUBLISHER). */
115+ tier: ListingTier;
116+ publisher: string;
117+ availability: Availability;
118+ /** Why it can't be connected here, when it can't. */
119+ why: string | null;
47120 /** How it is doing, when it is connected. */
48121 connected: ConnectedState | null;
49122 /** Where an owner connects it, or manages it once connected. */
50123 href: string | null;
124+ /** Its page in the Marketplace. */
125+ path: string;
51126 requested: boolean;
52127 waiting: number;
53128 };
54129
130+/** One connector as a listing in the workspace `slug`. */
131+function integrationListing(
132+ view: ConnectorView,
133+ scope: ConnectorScope,
134+ slug: string,
135+ connected: Record<string, ConnectedState> = {},
136+ unavailable: Record<string, string> = {},
137+ requests: InstallRequest[] = [],
138+ username = "",
139+): IntegrationListing {
140+ const ref = listingRef("integration", view.id);
141+ const state = scope === "workspace" ? (connected[view.id] ?? null) : null;
142+ const why = view.status === "available" && !state ? (unavailable[view.id] ?? null) : null;
143+ const availability: Availability = state ? "added" : view.status !== "available" ? "soon" : why ? "unavailable" : "available";
144+ const setup = view.href && !why ? connectorPath(view.href, slug) : null;
145+ return {
146+ ref,
147+ view,
148+ scope,
149+ tier: CONNECTOR_PUBLISHER.tier,
150+ publisher: CONNECTOR_PUBLISHER.name,
151+ availability,
152+ why,
153+ connected: state,
154+ href: state?.manage ?? setup,
155+ path: integrationPath(slug, view.id),
156+ requested: requests.some((request) => openFor(request, ref, username)),
157+ waiting: openCount(requests, ref),
158+ };
159+}
160+
55161 /**
56162 * The integrations a workspace can connect today, connected ones first,
57163 * each in catalog order. `connected` comes from the integrations service
58− * and the GitHub App (lib/connected.server.ts).
164+ * and the GitHub App (lib/connected.server.ts), as does `unavailable`:
165+ * connectors this g1t can't connect, with why.
59166 */
60167 export function integrationListings(
61168 views: ConnectorView[],
63170 requests: InstallRequest[],
64171 username: string,
65172 slug: string,
173+ unavailable: Record<string, string> = {},
66174 ): IntegrationListing[] {
67− const listings = views
68− .filter((view) => view.status === "available")
69− .map((view) => {
70− const ref = listingRef("integration", view.id);
71− const state = connected[view.id] ?? null;
72− const setup = view.href ? connectorPath(view.href, slug) : null;
73− return {
74− ref,
75− view,
76− connected: state,
77− href: state?.manage ?? setup,
78− requested: requests.some((request) => openFor(request, ref, username)),
79− waiting: openCount(requests, ref),
80− };
81− });
175+ const listings = views.filter((view) => view.status === "available").map((view) => integrationListing(view, "workspace", slug, connected, unavailable, requests, username));
82176 return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)];
83177 }
84178
179+/** What each person connects for themselves, available today, as listings. */
180+export function personalListings(views: ConnectorView[], slug: string, unavailable: Record<string, string> = {}): IntegrationListing[] {
181+ return views.filter((view) => view.status === "available" && view.href != null).map((view) => integrationListing(view, "personal", slug, {}, unavailable));
182+}
183+
184+/** The coming integrations (comingIntegrations), as listings: Soon, with nothing to press. */
185+export function comingListings(views: ConnectorView[], personal: ConnectorView[], slug: string): IntegrationListing[] {
186+ const workspace = new Set(views.map((view) => view.id));
187+ return comingIntegrations(views, personal).map((view) => integrationListing(view, workspace.has(view.id) ? "workspace" : "personal", slug));
188+}
189+
190+/** One integration's page in the Marketplace. */
191+export function integrationPath(slug: string, id: string): string {
192+ return `/${slug}/-/marketplace/integrations/${id}`;
193+}
194+
195+/** One way an integration is connected, and what it does there. */
196+export type IntegrationUse = {
197+ scope: ConnectorScope;
198+ /** Who connects it this way, in words. */
199+ who: string;
200+ description: string;
201+ capabilities: ConnectorCapability[];
202+};
203+
204+/**
205+ * What an integration does, today and soon: each way it is connected (for
206+ * the workspace, for each person) under whether that way is available yet,
207+ * from the catalog. Linear, say, works for a workspace today, and for each
208+ * person's own inbox soon.
209+ */
210+export function integrationUses(connector: Connector): { today: IntegrationUse[]; soon: IntegrationUse[] } {
211+ const today: IntegrationUse[] = [];
212+ const soon: IntegrationUse[] = [];
213+ for (const scope of connector.scopes) {
214+ const view = connectorView(connector, scope);
215+ if (!view) continue;
216+ const use = {
217+ scope,
218+ who: scope === "workspace" ? "For the whole workspace, connected once by an owner" : "For each person, with their own account",
219+ description: view.description,
220+ capabilities: view.capabilities,
221+ };
222+ (view.status === "available" ? today : soon).push(use);
223+ }
224+ return { today, soon };
225+}
226+
227+/** A connector category's title: `issues` is `Issues & projects`. */
228+export function categoryTitle(category: Connector["category"]): string {
229+ return CONNECTOR_CATEGORIES.find((c) => c.id === category)?.title ?? category;
230+}
231+
85232 /**
86233 * The integrations the catalog lists as coming: a workspace's, then those
87234 * each person connects for themselves (a calendar, a mailbox), once each.
93240 return [...workspace, ...own];
94241 }
95242
96−/** An extension in the Marketplace, with whether the workspace has it. */
243+/** An extension in the Marketplace, with who stands behind it and whether the workspace has it. */
97244 export type ExtensionListing = {
98245 ref: string;
99246 manifest: ExtensionManifest;
247+ /** Its publisher's tier. */
248+ tier: ListingTier;
249+ availability: Availability;
100250 install: ExtensionInstall | null;
101251 requested: boolean;
102252 waiting: number;
126276 export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] {
127277 const listings = manifests.map((manifest) => {
128278 const ref = listingRef("extension", manifest.id);
279+ const install = installs.find((install) => install.listing === ref) ?? null;
129280 return {
130281 ref,
131282 manifest,
132− install: installs.find((install) => install.listing === ref) ?? null,
283+ tier: manifest.publisher.tier,
284+ availability: (install ? "added" : manifest.status === "available" ? "available" : "soon") as Availability,
285+ install,
133286 requested: requests.some((request) => openFor(request, ref, username)),
134287 waiting: openCount(requests, ref),
135288 };
+1−0
218218 route("extensions", "routes/workspace/marketplace/extensions.tsx"),
219219 route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"),
220220 route("integrations", "routes/workspace/marketplace/integrations.tsx"),
221+ route("integrations/:integration", "routes/workspace/marketplace/integration.tsx"),
221222 route("requests", "routes/workspace/marketplace/requests.tsx"),
222223 ]),
223224 route("-/marketplace/agents", "routes/workspace/moved.ts", { id: "routes/workspace/moved-marketplace-agents" }),
+14−9
11 /**
2− * The Marketplace's front page: featured extensions, integrations
3− * (connected first), starter kits, and connected systems. Every listing is
4− * real: integrations a workspace can connect today, with their real state,
5− * and g1t's own extensions marked Soon until their first release.
2+ * The Marketplace's front page: featured extensions, who builds what (the
3+ * tiers, and what each availability means), integrations (connected
4+ * first), starter kits, and connected systems. Every listing is real:
5+ * integrations a workspace can connect today, with their real state, and
6+ * g1t's own extensions marked Soon until their first release. Every one
7+ * says its tier and whether it can be added here.
68 */
79 import { Link } from "react-router";
810
1012 import { connectorsFor } from "@g1t/contracts/connectors";
1113
1214 import type { Route } from "./+types/discover";
13−import { ComingBadge, ExtensionCard, ExtensionMark, IntegrationCard, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace";
15+import { AvailabilityBadge, ExtensionCard, ExtensionMark, IntegrationCard, ListingLegend, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace";
1416 import { STARTER_KITS, extensionListings, extensionPath, integrationListings, isConnectedSystem, marketplacePath } from "../../../lib/marketplace";
1517 import { loadConnected } from "../../../lib/marketplace.server";
1618 import { requireUser, roleIn } from "../../../lib/session.server";
2022 const viewer = requireUser(context, request);
2123 const slug = params.owner.toLowerCase();
2224 if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
23− return { connected: await loadConnected(slug, viewer) };
25+ return await loadConnected(slug, viewer);
2426 }
2527
2628 /** How many integrations the front page shows before "All". */
3133 export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) {
3234 const { slug, owner, username, requests, installs } = useMarketplace();
3335 const asked = requests?.requests ?? [];
34− const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug);
36+ const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug, loaderData.unavailable);
3537 const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username);
3638 const featured = FEATURED.map((id) => extensionById(id)).filter((manifest) => manifest != null);
3739 const bridges = extensions.filter((listing) => isConnectedSystem(listing.manifest));
5153 </div>
5254 </section>
5355
56+ <ListingLegend />
57+
5458 <section aria-labelledby="integrations">
5559 <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All integrations</SeeAll>}>
5660 Connect what your team already uses, so agents can read it and act in it. Each lists, in plain words, what it lets agents do.
99103 <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]" />
100104 <div className="relative flex items-center gap-2">
101105 <ExtensionMark manifest={manifest} size={large ? 36 : 28} />
102− <TierBadge tier={manifest.publisher.tier} />
103− {manifest.status !== "available" && <ComingBadge />}
106+ <TierBadge tier={manifest.publisher.tier} focusable={false} />
107+ <span className="text-xs text-muted">by {manifest.publisher.name}</span>
108+ {manifest.status !== "available" && <AvailabilityBadge availability="soon" kind="extension" hint="Not published yet. It can be installed from its first release." focusable={false} className="ml-auto" />}
104109 </div>
105110 <h3 className={`relative mt-3 font-semibold tracking-tight ${large ? "text-2xl" : "text-base"}`}>{manifest.name}</h3>
106111 <p className={`relative mt-1 max-w-xl text-muted ${large ? "text-[0.9375rem]" : "line-clamp-2 text-[0.8125rem]"}`}>{large ? manifest.description : manifest.tagline}</p>
+18−10
1010 import { dataDisclosure, extensionById } from "@g1t/contracts/marketplace";
1111
1212 import type { Route } from "./+types/extension";
13−import { ACTION, ComingBadge, ExtensionAction, ExtensionMark, TierBadge } from "../../../components/marketplace";
13+import { ACTION, ExtensionAction, ExtensionAvailability, ExtensionMark, ListingFacts } from "../../../components/marketplace";
1414 import { SubmitButton } from "../../../components/ui";
15−import { extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace";
15+import { TIERS, extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace";
1616 import { page } from "../../../lib/meta";
1717 import { useMarketplace } from "./layout";
1818
5656 <header className="mt-5 flex flex-wrap items-center gap-4">
5757 <ExtensionMark manifest={manifest} size={64} />
5858 <div className="min-w-0 grow basis-60">
59− <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight">
60− {manifest.name}
61− <TierBadge tier={manifest.publisher.tier} />
62− {manifest.status !== "available" && <ComingBadge />}
63− </h2>
64− <p className="mt-0.5 text-sm text-muted">
65− {manifest.publisher.name} · {manifest.category} · Free
66− </p>
59+ <h2 className="text-xl font-semibold tracking-tight">{manifest.name}</h2>
60+ <ListingFacts tier={listing!.tier} publisher={manifest.publisher.name} category={manifest.category}>
61+ <ExtensionAvailability listing={listing!} owner={owner} />
62+ </ListingFacts>
6763 </div>
6864 <ExtensionAction listing={listing!} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
6965 </header>
66+ {listing!.availability === "soon" && (
67+ <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted">
68+ Not published yet: nobody can install it until its first release. This page shows what it will add and what it will be able to do.
69+ </p>
70+ )}
7071 <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{manifest.description}</p>
7172
7273 <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
114115 </section>
115116 <dl className="space-y-3 rounded-xl border border-line bg-surface p-4">
116117 <div>
118+ <dt className="text-xs text-faint">Publisher</dt>
119+ <dd className="mt-0.5">
120+ {manifest.publisher.name} · {TIERS[listing!.tier].label}
121+ <span className="block text-xs text-muted">{TIERS[listing!.tier].about}</span>
122+ </dd>
123+ </div>
124+ <div>
117125 <dt className="text-xs text-faint">Version</dt>
118126 <dd className="mt-0.5">{install ? `${install.version} installed` : (manifest.version ?? "Not published yet")}</dd>
119127 </div>
+72−50
11 /**
22 * Extensions in the Marketplace: every listed extension, from its
3− * manifest (@g1t/contracts marketplace.ts), published ones first, with what
4− * the workspace installed; connected systems apart; then how extensions
5− * are published and who stands behind each tier.
3+ * manifest (@g1t/contracts marketplace.ts), by who builds it (Official,
4+ * Verified, Community, Internal), each tier a section even while it has
5+ * nothing, so it is plain what is whose. Filters by tier and availability
6+ * live in the address. Then how extensions are shared, and the legend.
67 */
7−import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace";
8+import { useSearchParams } from "react-router";
9+
10+import type { ListingTier } from "@g1t/contracts";
11+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
812
9−import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace";
10−import { TIERS, extensionListings, isConnectedSystem } from "../../../lib/marketplace";
13+import { ExtensionCard, ListingFilterBar, ListingLegend, SectionHead, TierBadge, TierEmpty, filterCounts } from "../../../components/marketplace";
14+import { type ExtensionListing, extensionListings, isConnectedSystem, passes, readFilters, tiersShown } from "../../../lib/marketplace";
1115 import { useMarketplace } from "./layout";
1216
17+/** Each tier's section heading on the Extensions tab. */
18+const HEADINGS: Record<ListingTier, string> = {
19+ official: "From g1t",
20+ verified: "From verified publishers",
21+ community: "From the community",
22+ internal: "Built in this workspace",
23+};
24+
1325 export default function MarketplaceExtensions() {
1426 const { slug, owner, username, requests, installs } = useMarketplace();
27+ const [params] = useSearchParams();
28+ const filters = readFilters(params);
29+ // A search or an availability filter can empty a tier; the tier filter alone only picks one.
30+ const filtered = filters.availability !== "all";
1531 const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username);
16− const published = listings.filter((l) => l.manifest.status === "available");
17− const coming = listings.filter((l) => l.manifest.status !== "available" && !isConnectedSystem(l.manifest));
18− const systems = listings.filter((l) => l.manifest.status !== "available" && isConnectedSystem(l.manifest));
32+ const shown = listings.filter((listing) => passes(listing, filters));
33+ const grid = (items: ExtensionListing[]) => (
34+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
35+ {items.map((listing) => (
36+ <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
37+ ))}
38+ </div>
39+ );
1940 return (
20− <div className="space-y-12">
21− {published.length > 0 && (
22− <section aria-labelledby="published">
23− <SectionHead id="published" title="Ready to install" aside={`${published.length}`} />
24− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
25− {published.map((listing) => (
26− <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
27− ))}
28− </div>
29− </section>
30− )}
31− <section aria-labelledby="soon">
32− <SectionHead id="soon" title={published.length > 0 ? "Soon" : "Soon from g1t"} aside={`${coming.length}`}>
33− Extensions add pages, data, cards and agent roles for what your team does besides code. These are g1t's own, listed before their first release; each can be installed once it is published.
34− </SectionHead>
35− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
36− {coming.map((listing) => (
37− <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
38− ))}
39− </div>
40− </section>
41− {systems.length > 0 && (
42− <section aria-labelledby="systems">
43− <SectionHead id="systems" title="Connected systems" aside={`${systems.length}`}>
44− Extensions that bridge a system your team already runs, so agents work across it and g1t together. The install screen names where its data goes.
45− </SectionHead>
46− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
47− {systems.map((listing) => (
48− <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
49− ))}
50− </div>
51− </section>
52− )}
41+ <div className="space-y-10">
42+ <ListingFilterBar kind="extension" filters={filters} counts={filterCounts(listings)} />
43+ {tiersShown(filters).map((tier) => {
44+ const mine = shown.filter((listing) => listing.tier === tier);
45+ const apps = mine.filter((listing) => !isConnectedSystem(listing.manifest));
46+ const systems = mine.filter((listing) => isConnectedSystem(listing.manifest));
47+ return (
48+ <section key={tier} aria-labelledby={`tier-${tier}`}>
49+ <SectionHead
50+ id={`tier-${tier}`}
51+ title={
52+ <>
53+ <TierBadge tier={tier} />
54+ {HEADINGS[tier]}
55+ </>
56+ }
57+ aside={`${mine.length}`}
58+ >
59+ {tier === "official" && mine.length > 0
60+ ? "g1t's own, listed before their first release so you can see what each adds and may do. Each can be installed once it is published."
61+ : null}
62+ </SectionHead>
63+ {mine.length === 0 ? (
64+ <TierEmpty tier={tier} kind="extension" filtered={filtered} />
65+ ) : (
66+ <div className="space-y-6">
67+ {apps.length > 0 && grid(apps)}
68+ {systems.length > 0 && (
69+ <div>
70+ <h3 className="text-sm font-semibold">Connected systems</h3>
71+ <p className="mt-0.5 mb-3 max-w-2xl text-sm text-muted">
72+ Extensions that bridge a system your team already runs, so agents work across it and g1t together. Each page names where its data goes.
73+ </p>
74+ {grid(systems)}
75+ </div>
76+ )}
77+ </div>
78+ )}
79+ </section>
80+ );
81+ })}
5382 <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
5483 <h2 id="how" className="text-base font-semibold tracking-tight">
5584 How extensions are shared
6190 <li>Its pages load in a sandboxed frame from g1t's user-content domain, and reach g1t only through what it was allowed.</li>
6291 <li>Every call it makes is in the audit log. Owners can cap what it spends, and switch it off at once.</li>
6392 </ul>
64− <ul className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
65− {LISTING_TIERS.map((tier) => (
66− <li key={tier} className="rounded-xl border border-line bg-bg p-4">
67− <TierBadge tier={tier} />
68− <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p>
69− </li>
70− ))}
71− </ul>
7293 </section>
94+ <ListingLegend kind="extension" />
7395 </div>
7496 );
7597 }
+178−0
1+/**
2+ * One integration's page: who builds it, whether this workspace can
3+ * connect it (or has), and exactly what it lets agents and people do
4+ * today and what is still Soon, from the connector catalog
5+ * (@g1t/contracts/connectors) for each way it is connected: for the
6+ * whole workspace, and for each person.
7+ */
8+import { ArrowLeft, Clock, ShieldCheck, Users } from "lucide-react";
9+import { Link, data } from "react-router";
10+
11+import { connectorById, connectorView } from "@g1t/contracts/connectors";
12+
13+import type { Route } from "./+types/integration";
14+import { ConnectorMark } from "../../../components/connectors";
15+import { AvailabilityBadge, IntegrationAction, ListingFacts } from "../../../components/marketplace";
16+import { cn } from "../../../lib/cn";
17+import { type IntegrationUse, TIERS, categoryTitle, comingListings, integrationListings, integrationUses, marketplacePath, personalListings } from "../../../lib/marketplace";
18+import { loadConnected } from "../../../lib/marketplace.server";
19+import { page } from "../../../lib/meta";
20+import { requireUser, roleIn } from "../../../lib/session.server";
21+import { useMarketplace } from "./layout";
22+
23+export function meta({ params, ...args }: Route.MetaArgs) {
24+ const connector = connectorById(params.integration);
25+ return page(args, { title: `${connector?.name ?? "Integration"} · Marketplace · ${params.owner} · g1t` });
26+}
27+
28+export async function loader({ params, context, request }: Route.LoaderArgs) {
29+ const viewer = requireUser(context, request);
30+ const slug = params.owner.toLowerCase();
31+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
32+ if (!connectorById(params.integration)) throw data(null, { status: 404 });
33+ return await loadConnected(slug, viewer);
34+}
35+
36+export default function MarketplaceIntegration({ params, loaderData }: Route.ComponentProps) {
37+ const { slug, owner, username, requests } = useMarketplace();
38+ const connector = connectorById(params.integration)!;
39+ const workspaceView = connectorView(connector, "workspace");
40+ const personalView = connectorView(connector, "personal");
41+ // The listing this page acts on: the workspace's, or each person's when it is only theirs.
42+ const listing = workspaceView
43+ ? workspaceView.status === "available"
44+ ? integrationListings([workspaceView], loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable)[0]!
45+ : comingListings([workspaceView], [], slug)[0]!
46+ : personalView!.status === "available" && personalView!.href
47+ ? personalListings([personalView!], slug, loaderData.unavailable)[0]!
48+ : comingListings([], [personalView!], slug)[0]!;
49+ const uses = integrationUses(connector);
50+ const tier = TIERS[listing.tier];
51+ return (
52+ <div>
53+ <Link to={marketplacePath(slug, "integrations")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
54+ <ArrowLeft size={14} />
55+ All integrations
56+ </Link>
57+ <header className="mt-5 flex flex-wrap items-center gap-4">
58+ <span className={cn("shrink-0", (listing.availability === "soon" || listing.availability === "unavailable") && "opacity-60 grayscale")}>
59+ <ConnectorMark view={listing.view} size={64} />
60+ </span>
61+ <div className="min-w-0 grow basis-60">
62+ <h2 className="text-xl font-semibold tracking-tight">{connector.name}</h2>
63+ <ListingFacts tier={listing.tier} publisher={listing.publisher} category={categoryTitle(connector.category)}>
64+ <AvailabilityBadge
65+ availability={listing.availability}
66+ kind="integration"
67+ why={listing.why}
68+ hint={listing.availability === "available" && !owner && listing.scope === "workspace" ? "An owner connects it for everyone. Ask one with Request." : undefined}
69+ />
70+ </ListingFacts>
71+ </div>
72+ <IntegrationAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
73+ </header>
74+ {listing.availability === "unavailable" && listing.why && (
75+ <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted">
76+ <span className="font-medium text-fg-soft">Not available here.</span> {listing.why}
77+ </p>
78+ )}
79+ {listing.availability === "soon" && (
80+ <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted">
81+ Planned, not built yet: nobody can connect it. This page shows what it will do.
82+ </p>
83+ )}
84+ <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{listing.view.description}</p>
85+
86+ <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
87+ <section aria-labelledby="does" className="min-w-0">
88+ <h3 id="does" className="text-sm font-semibold">
89+ What it lets agents and people do
90+ </h3>
91+ <div className="mt-3 grid gap-3 sm:grid-cols-2">
92+ <div>
93+ <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-success">
94+ <ShieldCheck size={13} />
95+ {listing.availability === "unavailable" ? "Once it can be connected here" : "Today"}
96+ </h4>
97+ {uses.today.length === 0 ? (
98+ <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing yet. It can't be connected until it is built.</p>
99+ ) : (
100+ <div className="space-y-3">
101+ {uses.today.map((use) => (
102+ <Use key={use.scope} use={use} said={listing.view.description} />
103+ ))}
104+ </div>
105+ )}
106+ </div>
107+ <div>
108+ <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-muted">
109+ <Clock size={13} />
110+ Soon
111+ </h4>
112+ {uses.soon.length === 0 ? (
113+ <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing more is planned for it yet.</p>
114+ ) : (
115+ <div className="space-y-3">
116+ {uses.soon.map((use) => (
117+ <Use key={use.scope} use={use} said={listing.view.description} soon />
118+ ))}
119+ </div>
120+ )}
121+ </div>
122+ </div>
123+ </section>
124+
125+ <aside className="text-sm">
126+ <dl className="space-y-3 rounded-xl border border-line bg-surface p-4">
127+ <div>
128+ <dt className="text-xs text-faint">Publisher</dt>
129+ <dd className="mt-0.5">
130+ {listing.publisher} · {tier.label}
131+ <span className="block text-xs text-muted">{tier.about}</span>
132+ </dd>
133+ </div>
134+ <div>
135+ <dt className="flex items-center gap-1.5 text-xs text-faint">
136+ <Users size={12} />
137+ Who connects it
138+ </dt>
139+ <dd className="mt-0.5">
140+ {connector.scopes.includes("workspace") && connector.scopes.includes("personal")
141+ ? "An owner, once for the whole workspace; and each person, for their own account."
142+ : connector.scopes.includes("workspace")
143+ ? "An owner, once for the whole workspace."
144+ : "Each person, for their own account. Agents use it only when that person asks."}
145+ </dd>
146+ </div>
147+ {listing.connected && (
148+ <div>
149+ <dt className="text-xs text-faint">Connected</dt>
150+ <dd className="mt-0.5 break-words">{listing.connected.detail}</dd>
151+ {listing.connected.problem && <dd className="mt-1 text-warn">{listing.connected.problem}</dd>}
152+ </div>
153+ )}
154+ </dl>
155+ </aside>
156+ </div>
157+ </div>
158+ );
159+}
160+
161+/** One way it is connected: who connects it, what it does that way, and its capabilities. */
162+function Use({ use, said, soon = false }: { use: IntegrationUse; said: string; soon?: boolean }) {
163+ return (
164+ <div className={cn("rounded-xl border p-4", soon ? "border-dashed border-line-strong/70" : "border-line bg-surface")}>
165+ <p className="text-xs text-faint">{use.who}</p>
166+ {use.description !== said && <p className={cn("mt-1 text-sm", soon ? "text-muted" : "text-fg-soft")}>{use.description}</p>}
167+ {use.capabilities.length > 0 && (
168+ <ul className="mt-2.5 flex flex-wrap gap-1.5" aria-label={soon ? "What it will do" : "What it does"}>
169+ {use.capabilities.map((capability) => (
170+ <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", soon ? "border border-dashed border-line text-faint" : "bg-raised text-fg-soft")}>
171+ {capability}
172+ </li>
173+ ))}
174+ </ul>
175+ )}
176+ </div>
177+ );
178+}
+124−105
11 /**
2− * Integrations in the Marketplace: every connector a workspace can connect
3− * today, connected ones first; those each person connects for themselves;
4− * then the ones the catalog lists as coming. Each workspace one is set up
5− * on its own page under Workspace → Integrations; owners connect, anyone
6− * else asks.
2+ * Integrations in the Marketplace, by who builds them: every connector is
3+ * g1t's own (Official), so that section holds the ones connected, the ones
4+ * a workspace can connect today, any this g1t can't connect (with why),
5+ * those each person connects for themselves, and the ones the catalog
6+ * lists as coming; Verified, Community and Internal are sections too,
7+ * empty and saying so. Search, and filters by tier and availability, kept
8+ * in the address. Each workspace one is set up on its own page under
9+ * Workspace → Integrations; owners connect, anyone else asks.
710 */
811 import { Search } from "lucide-react";
912 import { useState } from "react";
10−import { Link } from "react-router";
13+import { Link, useSearchParams } from "react-router";
1114
12−import { type ConnectorView, connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors";
15+import { CONNECTORS, connectorView, connectorsFor } from "@g1t/contracts/connectors";
1316
1417 import type { Route } from "./+types/integrations";
15−import { ConnectorMark } from "../../../components/connectors";
16−import { ComingBadge, IntegrationCard, SectionHead } from "../../../components/marketplace";
17−import { comingIntegrations, integrationListings, integrationMatches } from "../../../lib/marketplace";
18+import { IntegrationCard, IntegrationRow, ListingFilterBar, ListingLegend, SectionHead, TierBadge, TierEmpty, filterCounts } from "../../../components/marketplace";
19+import { type IntegrationListing, comingListings, integrationListings, integrationMatches, passes, personalListings, readFilters, tiersShown } from "../../../lib/marketplace";
1820 import { loadConnected } from "../../../lib/marketplace.server";
1921 import { requireUser, roleIn } from "../../../lib/session.server";
2022 import { useMarketplace } from "./layout";
2325 const viewer = requireUser(context, request);
2426 const slug = params.owner.toLowerCase();
2527 if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
26− return { connected: await loadConnected(slug, viewer) };
28+ return await loadConnected(slug, viewer);
2729 }
2830
31+const HEADINGS = { official: "From g1t", verified: "From verified publishers", community: "From the community", internal: "Built in this workspace" } as const;
32+
2933 export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) {
3034 const { slug, owner, username, requests } = useMarketplace();
3135 const [query, setQuery] = useState("");
36+ const [params] = useSearchParams();
37+ const filters = readFilters(params);
3238 const views = connectorsFor("workspace");
33− const all = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug);
34− const shown = all.filter((listing) => integrationMatches(listing, query));
35− const connected = shown.filter((listing) => listing.connected);
36− const available = shown.filter((listing) => !listing.connected);
39+ const personalViews = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view) => view != null);
40+ const workspace = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable);
41+ // What each person connects for themselves, today; and what is coming, for either.
42+ const yours = personalListings(personalViews, slug, loaderData.unavailable);
43+ const coming = comingListings(
44+ views,
45+ personalViews.filter((view) => !CONNECTORS.find((c) => c.id === view.id)?.scopes.includes("workspace")),
46+ slug,
47+ );
48+ const everything = [...workspace, ...yours, ...coming];
3749 const wanted = query.trim().toLowerCase();
38− const personal = CONNECTORS.filter((c) => !c.scopes.includes("workspace")).map((c) => connectorView(c, "personal")).filter((view) => view != null);
39− // What each person connects for themselves, today.
40− const yours = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view): view is ConnectorView & { href: string } => view != null && view.status === "available" && view.href != null && (!wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted))));
41− const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted)));
50+ const keep = (listing: IntegrationListing) => integrationMatches(listing, query) && passes(listing, filters);
51+ // A search or an availability filter can empty a tier; the tier filter alone only picks one.
52+ const filtered = wanted !== "" || filters.availability !== "all";
53+ // A plain function, not a component made each render: that would remount the cards (and close a request's dialog).
54+ const grid = (items: IntegrationListing[]) => (
55+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
56+ {items.map((listing) => (
57+ <IntegrationCard key={`${listing.scope}:${listing.ref}`} listing={listing} slug={slug} owner={owner} />
58+ ))}
59+ </div>
60+ );
4261 return (
4362 <div className="space-y-10">
44− <div className="flex flex-wrap items-center justify-between gap-3">
45− <label className="flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-sm focus-within:border-line-strong">
46− <Search size={15} className="shrink-0 text-faint" />
47− <input
48− value={query}
49− onChange={(event) => setQuery(event.target.value)}
50− placeholder="Find an integration"
51− aria-label="Find an integration"
52− autoComplete="off"
53− className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
54− />
55− </label>
56− <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg">
57− Workspace integrations settings
58− </Link>
63+ <div className="space-y-4">
64+ <div className="flex flex-wrap items-center justify-between gap-3">
65+ <label className="flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-sm focus-within:border-line-strong">
66+ <Search size={15} className="shrink-0 text-faint" />
67+ <input
68+ value={query}
69+ onChange={(event) => setQuery(event.target.value)}
70+ placeholder="Find an integration"
71+ aria-label="Find an integration"
72+ autoComplete="off"
73+ className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
74+ />
75+ </label>
76+ <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg">
77+ Workspace integrations settings
78+ </Link>
79+ </div>
80+ <ListingFilterBar kind="integration" filters={filters} counts={filterCounts(everything)} />
5981 </div>
60−
61− {connected.length > 0 && (
62− <section aria-labelledby="connected">
63− <SectionHead id="connected" title="Connected" aside={`${connected.length}`} />
64− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
65− {connected.map((listing) => (
66− <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
67− ))}
68− </div>
69− </section>
70− )}
7182
72− <section aria-labelledby="available">
73− <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`}>
74− Connected once for the whole workspace by an owner. Each says what it lets agents do.
75− </SectionHead>
76− {available.length === 0 ? (
77− <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">{wanted ? "No integration you can connect matches." : "Everything available is connected."}</p>
78− ) : (
79− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
80− {available.map((listing) => (
81− <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
82− ))}
83− </div>
84− )}
85− </section>
86−
87− {yours.length > 0 && (
88− <section aria-labelledby="yours">
89− <SectionHead id="yours" title="Connected by each person" aside={`${yours.length}`}>
90− Each person connects these for themselves, and agents use them only when that person asks.
91− </SectionHead>
92− <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
93− {yours.map((view) => (
94− <li key={view.id} className="flex flex-col rounded-xl border border-line bg-surface p-4">
95− <div className="flex grow items-start gap-3">
96− <ConnectorMark view={view} size={40} />
97− <div className="min-w-0 grow">
98− <h3 className="truncate text-sm font-semibold">{view.name}</h3>
99− <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
100− </div>
101− </div>
102− <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
103− <span className="truncate text-xs text-faint">Each person's own</span>
104− <Link to={view.href} className="inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg">
105− Connect yours
106− </Link>
107− </div>
108− </li>
109− ))}
110− </ul>
111− </section>
112− )}
113−
114− {coming.length > 0 && (
115− <section aria-labelledby="coming">
116− <SectionHead id="coming" title="Coming" aside={<ComingBadge />}>
117− Planned, not built. Nobody can connect these yet.
118− </SectionHead>
119− <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
120− {coming.map((view) => (
121− <li key={view.id} className="flex items-center gap-3 rounded-lg border border-dashed border-line px-3 py-2.5">
122− <ConnectorMark view={view} size={28} />
123− <span className="min-w-0">
124− <span className="block truncate text-sm">{view.name}</span>
125− <span className="block truncate text-xs text-faint">{view.description}</span>
126− </span>
127− </li>
128− ))}
129− </ul>
130− </section>
131− )}
83+ {tiersShown(filters).map((tier) => {
84+ const connected = workspace.filter((l) => l.tier === tier && l.availability === "added" && keep(l));
85+ const available = workspace.filter((l) => l.tier === tier && l.availability === "available" && keep(l));
86+ const blocked = workspace.filter((l) => l.tier === tier && l.availability === "unavailable" && keep(l));
87+ const own = yours.filter((l) => l.tier === tier && keep(l));
88+ const soon = coming.filter((l) => l.tier === tier && keep(l));
89+ const count = connected.length + available.length + blocked.length + own.length + soon.length;
90+ return (
91+ <section key={tier} aria-labelledby={`tier-${tier}`} className="space-y-8">
92+ <SectionHead
93+ id={`tier-${tier}`}
94+ title={
95+ <>
96+ <TierBadge tier={tier} />
97+ {HEADINGS[tier]}
98+ </>
99+ }
100+ aside={`${count}`}
101+ >
102+ {tier === "official" && count > 0 ? "Every connector in the catalog is built and supported by g1t. Each says what it lets agents do." : null}
103+ </SectionHead>
104+ {count === 0 && <TierEmpty tier={tier} kind="integration" filtered={filtered} />}
105+ {connected.length > 0 && (
106+ <div>
107+ <SectionHead sub id={`${tier}-connected`} title="Connected" aside={`${connected.length}`} />
108+ {grid(connected)}
109+ </div>
110+ )}
111+ {available.length > 0 && (
112+ <div>
113+ <SectionHead sub id={`${tier}-available`} title="Available to connect" aside={`${available.length}`}>
114+ Connected once for the whole workspace by an owner.
115+ </SectionHead>
116+ {grid(available)}
117+ </div>
118+ )}
119+ {blocked.length > 0 && (
120+ <div>
121+ <SectionHead sub id={`${tier}-unavailable`} title="Not available here" aside={`${blocked.length}`}>
122+ This g1t lacks something these need. Each says what.
123+ </SectionHead>
124+ {grid(blocked)}
125+ </div>
126+ )}
127+ {own.length > 0 && (
128+ <div>
129+ <SectionHead sub id={`${tier}-yours`} title="Connected by each person" aside={`${own.length}`}>
130+ Each person connects these for themselves, and agents use them only when that person asks.
131+ </SectionHead>
132+ {grid(own)}
133+ </div>
134+ )}
135+ {soon.length > 0 && (
136+ <div>
137+ <SectionHead sub id={`${tier}-soon`} title="Soon" aside={`${soon.length}`}>
138+ Planned, not built. Nobody can connect these yet.
139+ </SectionHead>
140+ <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
141+ {soon.map((listing) => (
142+ <IntegrationRow key={listing.ref} listing={listing} />
143+ ))}
144+ </ul>
145+ </div>
146+ )}
147+ </section>
148+ );
149+ })}
150+ <ListingLegend kind="integration" />
132151 </div>
133152 );
134153 }
+6−0
329329
330330 export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
331331
332+/**
333+ * Who stands behind every integration: the connector catalog
334+ * (./connectors.ts) is g1t's own, so each connector is Official.
335+ */
336+export const CONNECTOR_PUBLISHER: { name: string; tier: ListingTier } = { name: "g1t", tier: "official" };
337+
332338 /** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */
333339 export type ListingRef = { kind: ListingKind; id: string };
334340