Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge branch 'worktree-agent-a432a6fe93007b723' into customize

syntaqxcommitted Parentsaaa3c95636939fBrowse files
57 files+4589−27770/57 viewed
+6−3
1+import { pluginRef } from "@g1t/contracts/claude-plugin";
12 import { connectorPath, connectorsFor } from "@g1t/contracts/connectors";
23 import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize";
34 import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
89 * (packages/contracts/src/customize.ts). What is listed comes from the
910 * catalogs every g1t ships with; a workspace's own library, connections
1011 * and installs are read on the site for now, and added here once the API
11− * serves them.
12+ * serves them. Every item opens where the site shows it: a connector on
13+ * Connectors' Discover (an MCP server on its connect page), g1t's own
14+ * announced plugins on their pages in the `g1t` marketplace.
1215 */
1316
1417 export type CustomizeItem = {
4245 description: connector.description,
4346 status: connector.status,
4447 group: connector.status === "available" ? "Connect now" : "Coming",
45− sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}/${encodeURIComponent(connector.id)}`,
48+ sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}?view=discover&directory=${encodeURIComponent(connector.id)}`,
4649 }));
4750 }
4851 return FIRST_PARTY_EXTENSIONS.map((extension) => ({
5154 description: extension.tagline,
5255 status: extension.status,
5356 group: `${extension.publisher.name} · ${extension.category}`,
54− sitePath: `${fallback}/${encodeURIComponent(extension.id)}`,
57+ sitePath: customizePath("plugins", workspace, encodeURIComponent(pluginRef(extension.id, "g1t"))),
5558 }));
5659 }
5760
+10−1
2525 assert.equal(routeFor("/acme/rocket/issues/new"), null);
2626 });
2727
28−test("the Marketplace, integrations and skills open Customize", () => {
28+test("Customize's tabs open the app's Customize; its other pages stay on the site", () => {
29+ assert.equal(routeFor("/acme/-/customize"), "/customize?workspace=acme");
30+ assert.equal(routeFor("/acme/-/customize/plugins"), "/customize/plugins?workspace=acme");
31+ assert.equal(routeFor("/acme/-/customize/connectors?view=discover"), "/customize/connectors?workspace=acme");
32+ assert.equal(routeFor("/acme/-/customize/plugins/pdf%40anthropic-agent-skills"), null);
33+ assert.equal(routeFor("/acme/-/customize/requests"), null);
34+});
35+
36+test("the Marketplace, integrations and the old skill library open Customize", () => {
2937 assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme");
3038 assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme");
39+ assert.equal(routeFor("/acme/-/agents/skills/pdf"), null, "a skill's own page is the site's");
3140 });
3241
3342 test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => {
+13−1
1111 /** Where the app's OAuth sign-in comes back to. */
1212 export const OAUTH_CALLBACK_PATH = "/oauth/callback";
1313
14+/** Customize's tabs, each a screen of the app's Customize. */
15+const CUSTOMIZE_TABS = new Set(["skills", "connectors", "plugins"]);
16+
1417 /** Path segments a workspace or account can never be named (the site's own pages). */
1518 const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
1619
3942 return null;
4043 }
4144 if (third === "overview" && !fourth) return `/code?${workspace}`;
42− if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills")) {
45+ // Customize's tabs open the app's Customize, on the same tab; its other
46+ // pages (a plugin, a connector, requests) open on the site. The
47+ // Marketplace, the integrations settings and the old skill library were
48+ // Customize before it had the name.
49+ if (third === "customize") {
50+ if (!fourth) return `/customize?${workspace}`;
51+ if (CUSTOMIZE_TABS.has(fourth) && !fifth) return `/customize/${fourth}?${workspace}`;
52+ return null;
53+ }
54+ if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills" && !fifth)) {
4355 return `/customize?${workspace}`;
4456 }
4557 return null;
+1−1
169169 <SideLink to={`/${slug}/-/agents/templates`} icon={<LayoutTemplate size={15} className="text-faint" />}>
170170 Templates
171171 </SideLink>
172− <SideLink to={`/${slug}/-/agents/skills`} icon={<BookMarked size={15} className="text-faint" />}>
172+ <SideLink to={`/${slug}/-/customize/skills`} icon={<BookMarked size={15} className="text-faint" />}>
173173 Skills
174174 </SideLink>
175175 {code && (
+23−5
22 import { useId } from "react";
33 import { Link, useFetcher } from "react-router";
44
5−import type { Ability, AbilityLevel, AbilitySection, AbilitySource, McpServer } from "@g1t/contracts";
5+import type { Ability, AbilityLevel, AbilitySection, AbilitySource, AgentConnector, McpServer } from "@g1t/contracts";
66 import { ABILITY_LEVEL_LABELS } from "@g1t/contracts/abilities";
7+import { customizePath } from "@g1t/contracts/customize";
78
89 import { cn } from "../../lib/cn";
10+import { connectorPagePath } from "../../lib/customize";
11+import { StateBadge } from "../customize";
912 import { ButtonLink, SubmitButton } from "../ui";
1013 import { Button } from "../ui/button";
1114 import { Badge } from "../ui/badge";
131134 );
132135 }
133136
134−/** A source's block: its name, whether it is connected, and its rows. */
137+/**
138+ * A source's block: its name, whether it is connected, and its rows. An MCP
139+ * source is one of the workspace's connectors (`connector`: how it is
140+ * doing and how the agent has it, with its page in Customize) or a server
141+ * only this agent has, which owners list again or remove here.
142+ */
135143 export function SourceBlock({
136144 section,
137145 source,
140148 isOwner,
141149 slug,
142150 fetcher,
151+ connector = null,
143152 }: {
144153 section: AbilitySection;
145154 source: AbilitySource;
148157 isOwner: boolean;
149158 slug: string;
150159 fetcher: Fetcher;
160+ connector?: AgentConnector | null;
151161 }) {
152162 const integration = section.group === "integration";
153163 const mcp = section.group === "mcp";
154− const askHref = `/${slug}/-/marketplace/integrations/${source.id}`;
164+ const shared = mcp && connector != null && connector.via !== "agent-only";
165+ const askHref = `${customizePath("connectors", slug)}?view=discover&directory=${encodeURIComponent(source.id)}`;
155166 return (
156167 <article className="overflow-hidden rounded-xl border border-line bg-surface" aria-labelledby={`source-${source.id}`}>
157168 {(integration || mcp) && (
161172 {source.name}
162173 </h3>
163174 {integration && <Badge tone={source.connected ? "success" : "neutral"}>{source.connected ? "Connected" : "Not connected"}</Badge>}
164− {mcp && (
175+ {shared && <StateBadge state={connector.state} />}
176+ {mcp && <Badge tone={shared ? "accent" : "neutral"}>{connector?.via_label ?? "Only this agent"}</Badge>}
177+ {mcp && !shared && (
165178 <a href={source.href ?? "#"} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-fg">
166179 {source.href}
167180 <ExternalLink size={11} aria-hidden />
175188 Manage
176189 </Link>
177190 )}
178− {mcp && isOwner && (
191+ {shared && (
192+ <Link to={connectorPagePath(slug, source.id)} className="text-xs text-muted underline-offset-2 hover:underline">
193+ Open in Customize
194+ </Link>
195+ )}
196+ {mcp && !shared && isOwner && (
179197 <>
180198 <fetcher.Form method="post">
181199 <input type="hidden" name="intent" value="mcp_refresh" />
+2−1
1616 import { SelectField } from "../ui/select";
1717 import { useDialogFetcher } from "./dialogs";
1818
19−/** `/acme/-/agents/skills`, or one skill's page. */
19+/** One skill's page, the editor or import under `/acme/-/agents/skills`; the library itself is Customize's Skills tab. */
2020 export function skillsPath(slug: string, name?: string, rest = ""): string {
21+ if (!name && !rest) return `/${slug}/-/customize/skills`;
2122 return `/${slug}/-/agents/skills${name ? `/${encodeURIComponent(name)}` : ""}${rest}`;
2223 }
2324
+18−17
1−import { Bell, Building2, Code2, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, Sparkles, Store, House, Users } from "lucide-react";
1+import { Bell, Building2, Code2, House, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, SlidersHorizontal, Sparkles, Users } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useState } from "react";
33 import { Link, useFetcher, useFetchers } from "react-router";
44
5+import { customizePath } from "@g1t/contracts/customize";
6+
57 import { ConnectorMark } from "./connectors";
6−import { TierBadge } from "./marketplace";
78 import { Card } from "./ui/card";
89 import { Skeleton } from "./ui/skeleton";
910 import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps";
2526 return icons[key];
2627 }
2728
28−/** An installed app's mark: its connector's, until extensions bring their own. */
29+/** An installed app's mark: its connector's, or its letters on a steady colour. */
2930 export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) {
3031 return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />;
3132 }
9798 </span>
9899 {card ? (
99100 <span className="min-w-0">
100− <span className="flex min-w-0 items-center gap-2">
101+ <span className="flex min-w-0 items-baseline gap-2">
101102 <span className="truncate text-sm font-medium text-fg">{app.name}</span>
102− <TierBadge tier={app.tier} focusable={false} />
103+ <span className="shrink-0 text-[0.6875rem] text-faint">{app.from}</span>
103104 </span>
104105 <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span>
105106 </span>
106107 ) : (
107108 <span className="flex w-full min-w-0 flex-col items-center gap-1">
108109 <span className="block w-full truncate text-xs font-medium text-fg-soft">{app.name}</span>
109− {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>}
110+ <span className={cn("block truncate text-[0.625rem]", app.usable ? "text-faint" : "text-accent")}>{app.usable ? app.from : "Request access"}</span>
110111 </span>
111112 )}
112113 </>
126127 <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}>
127128 {body}
128129 </Link>
129− {app.usable && (
130+ {app.usable && app.pinnable && (
130131 <Hint label={pinned ? "Unpin from your rail" : "Pin to your rail"}>
131132 <button
132133 type="button"
147148 );
148149 }
149150
150−/** When nothing is installed: what Apps are, and the way to the Marketplace. */
151+/** When nothing is installed: what Apps are, and the way to Customize. */
151152 export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) {
152153 return (
153154 <div className={cn("flex flex-col items-center text-center", compact ? "px-4 py-6" : "rounded-xl border border-dashed border-line px-6 py-12")}>
154155 <span className="flex size-10 items-center justify-center rounded-xl bg-raised text-muted ring-1 ring-line">
155− <Store size={18} />
156+ <SlidersHorizontal size={18} />
156157 </span>
157158 <p className="mt-3 text-sm font-medium">No apps installed yet</p>
158− <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from the Marketplace: integrations your workspace connects, and extensions later. Each one you can use shows here, to pin to your rail.</p>
159− <Link to={`/${slug}/-/marketplace`} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover">
160− <Store size={14} />
161− Browse the Marketplace
159+ <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from Customize: the plugins your workspace installs, and the connectors and integrations it connects. Each one you can use shows here.</p>
160+ <Link to={customizePath("", slug)} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover">
161+ <SlidersHorizontal size={14} />
162+ Customize
162163 </Link>
163164 </div>
164165 );
167168 /**
168169 * The Apps launcher's body: a search, every app the workspace installed
169170 * that the person can use (read from the Apps page as it opens), and the
170− * way to all of them and to the Marketplace.
171+ * way to all of them and to Customize.
171172 */
172173 export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) {
173174 const [query, setQuery] = useState("");
234235 All apps
235236 </Link>
236237 <Link
237− to={`/${slug}/-/marketplace`}
238+ to={customizePath("", slug)}
238239 onClick={onClose}
239240 className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg"
240241 >
241− <Store size={14} />
242− Marketplace
242+ <SlidersHorizontal size={14} />
243+ Customize
243244 </Link>
244245 </div>
245246 </div>
+186−0
1+/**
2+ * A connector's pieces, shared by Customize → Connectors and a connector's
3+ * own page: its mark, the menu of what an owner does with it (sign in or
4+ * out, a key, listing its tools again, removing it), the dialog that takes
5+ * a key, and the control that says which agents it reaches. Every form
6+ * posts to the page it is on; lib/customize.server.ts answers them.
7+ */
8+import { EllipsisVertical, KeyRound, LogIn, LogOut, RefreshCw, Trash2 } from "lucide-react";
9+import { type ReactNode, useEffect, useId, useState } from "react";
10+import { useFetcher } from "react-router";
11+
12+import type { WorkspaceConnector } from "@g1t/contracts";
13+import { connectorById } from "@g1t/contracts/connectors";
14+
15+import { type ActionResult, Confirm } from "./agents/dialogs";
16+import { ConnectorMark } from "./connectors";
17+import { AvailableTo, ListingMark } from "./customize";
18+import { SubmitButton } from "./ui";
19+import { Button } from "./ui/button";
20+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
21+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
22+import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
23+import { Input } from "./ui/input";
24+import { type Reach, type ReachOption, type ReachTargets } from "../lib/customize";
25+
26+/** A connector's mark: the directory entry's when it came from one, its own letters otherwise. */
27+export function WorkspaceConnectorMark({ connector, size = 40 }: { connector: Pick<WorkspaceConnector, "id" | "title" | "from">; size?: number }) {
28+ const entry = connector.from.kind === "directory" ? connectorById(connector.from.id) : null;
29+ if (entry) return <ConnectorMark view={{ id: entry.id, name: entry.name, provider: entry.provider ?? null }} size={size} />;
30+ return <ListingMark id={connector.id} name={connector.title} size={size} />;
31+}
32+
33+/** Where a connector reaches, as the control reads it. */
34+export function connectorReach(connector: Pick<WorkspaceConnector, "grants">): Reach[] {
35+ return connector.grants.map((grant) => ({ id: grant.id, scope: grant.scope, target: grant.target, label: grant.label }));
36+}
37+
38+/** Which agents a connector is available to, with the control that changes it for owners. */
39+export function ConnectorReach({ connector, targets, size }: { connector: Pick<WorkspaceConnector, "id" | "grants">; targets: ReachTargets | null; size?: "xs" | "sm" }) {
40+ const fetcher = useFetcher<ActionResult>({ key: `connector-reach-${connector.id}` });
41+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
42+ const change = (option: ReachOption, checked: boolean) => {
43+ if (checked) fetcher.submit({ intent: "grant", connector: connector.id, where: option.value }, { method: "post" });
44+ else if (option.reach) fetcher.submit({ intent: "revoke", connector: connector.id, grant: option.reach.id }, { method: "post" });
45+ };
46+ return (
47+ <>
48+ <AvailableTo reach={connectorReach(connector)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} size={size} />
49+ {error && <p className="text-xs text-danger">{error}</p>}
50+ </>
51+ );
52+}
53+
54+/**
55+ * A key for a connector that takes one: the header's name (Authorization,
56+ * usually) and its value, which is sealed and never shown again. `caveat`
57+ * is what the directory knows about the key the server wants. With
58+ * `connect` (a directory entry's id) it connects that entry with the key
59+ * in one step, instead of changing a connector's.
60+ */
61+export function SetKeyDialog({
62+ connector,
63+ caveat,
64+ connect,
65+ trigger,
66+ open,
67+ onOpenChange,
68+}: {
69+ connector: Pick<WorkspaceConnector, "id" | "title" | "header_name">;
70+ caveat?: string | null;
71+ connect?: string;
72+ trigger?: ReactNode;
73+ open?: boolean;
74+ onOpenChange?: (open: boolean) => void;
75+}) {
76+ const fetcher = useFetcher<ActionResult>({ key: `connector-key-${connector.id}` });
77+ const [own, setOwn] = useState(false);
78+ const isOpen = open ?? own;
79+ const setOpen = onOpenChange ?? setOwn;
80+ const id = useId();
81+ useEffect(() => {
82+ if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
83+ }, [fetcher.state, fetcher.data, setOpen]);
84+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
85+ const fields = connect ? { intent: "connect", directory: connect } : { intent: "key", connector: connector.id };
86+ return (
87+ <Dialog open={isOpen} onOpenChange={setOpen}>
88+ {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
89+ <DialogContent>
90+ <DialogHeader>
91+ <DialogTitle>{connect ? `Connect ${connector.title}` : `A key for ${connector.title}`}</DialogTitle>
92+ <DialogDescription>Sent with every call as a header. It is sealed when saved and never shown again; paste a new one to replace it.</DialogDescription>
93+ </DialogHeader>
94+ {caveat && <p className="text-sm text-fg-soft">{caveat}</p>}
95+ <fetcher.Form method="post" className="grid gap-5">
96+ {Object.entries(fields).map(([name, value]) => (
97+ <input key={name} type="hidden" name={name} value={value} />
98+ ))}
99+ <Field>
100+ <FieldLabel htmlFor={`${id}-header`}>Header</FieldLabel>
101+ <Input id={`${id}-header`} name="header_name" defaultValue={connector.header_name ?? "Authorization"} required autoComplete="off" spellCheck={false} className="font-mono" />
102+ </Field>
103+ <Field>
104+ <FieldLabel htmlFor={`${id}-value`}>Value</FieldLabel>
105+ <Input id={`${id}-value`} name="value" type="password" placeholder="Bearer …" required autoComplete="off" spellCheck={false} className="font-mono" />
106+ <FieldDescription>The whole value, scheme included, such as Bearer and the token.</FieldDescription>
107+ </Field>
108+ <FieldError>{error}</FieldError>
109+ <DialogFooter>
110+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
111+ Cancel
112+ </Button>
113+ <SubmitButton variant="accent" fetcher={fetcher} match={fields} pending={connect ? "Connecting…" : "Saving and listing tools…"}>
114+ <KeyRound size={14} />
115+ {connect ? "Connect" : "Save key"}
116+ </SubmitButton>
117+ </DialogFooter>
118+ </fetcher.Form>
119+ </DialogContent>
120+ </Dialog>
121+ );
122+}
123+
124+/**
125+ * What an owner does with a connector: sign it in (the browser leaves for
126+ * the server) or out, set a key, list its tools again, or remove it, which
127+ * is asked twice.
128+ */
129+export function ConnectorMenu({ connector, caveat }: { connector: WorkspaceConnector; caveat?: string | null }) {
130+ const fetcher = useFetcher<ActionResult>({ key: `connector-menu-${connector.id}` });
131+ const [key, setKey] = useState(false);
132+ const post = (intent: string) => fetcher.submit({ intent, connector: connector.id }, { method: "post" });
133+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
134+ return (
135+ <>
136+ <DropdownMenu>
137+ <DropdownMenuTrigger asChild>
138+ <Button type="button" variant="ghost" size="icon-sm" aria-label={`What to do with ${connector.title}`} disabled={fetcher.state !== "idle"}>
139+ <EllipsisVertical size={15} />
140+ </Button>
141+ </DropdownMenuTrigger>
142+ <DropdownMenuContent align="end">
143+ {connector.auth === "oauth" && (
144+ <DropdownMenuItem onSelect={() => post(connector.state === "needs_sign_in" ? "sign_in" : "sign_out")}>
145+ {connector.state === "needs_sign_in" ? <LogIn /> : <LogOut />}
146+ {connector.state === "needs_sign_in" ? "Sign in" : "Sign out"}
147+ </DropdownMenuItem>
148+ )}
149+ {connector.auth === "oauth" && connector.state !== "needs_sign_in" && (
150+ <DropdownMenuItem onSelect={() => post("sign_in")}>
151+ <LogIn />
152+ Sign in again
153+ </DropdownMenuItem>
154+ )}
155+ {connector.auth === "header" && (
156+ <DropdownMenuItem onSelect={() => setKey(true)}>
157+ <KeyRound />
158+ {connector.state === "needs_key" ? "Set the key" : "Replace the key"}
159+ </DropdownMenuItem>
160+ )}
161+ <DropdownMenuItem onSelect={() => post("refresh")}>
162+ <RefreshCw />
163+ List tools again
164+ </DropdownMenuItem>
165+ <DropdownMenuSeparator />
166+ <Confirm
167+ title={`Remove ${connector.title}?`}
168+ confirm="Remove connector"
169+ fields={{ intent: "remove", connector: connector.id }}
170+ fetcherKey={`connector-remove-${connector.id}`}
171+ trigger={
172+ <DropdownMenuItem onSelect={(event) => event.preventDefault()} className="text-danger data-highlighted:text-danger [&_svg]:text-danger">
173+ <Trash2 />
174+ Remove
175+ </DropdownMenuItem>
176+ }
177+ >
178+ Its tools stop being offered to every agent it reaches, at once, and its sign-in or key is forgotten.
179+ </Confirm>
180+ </DropdownMenuContent>
181+ </DropdownMenu>
182+ {connector.auth === "header" && <SetKeyDialog connector={connector} caveat={caveat} open={key} onOpenChange={setKey} />}
183+ {error && <p className="text-xs text-danger">{error}</p>}
184+ </>
185+ );
186+}
+456−0
1+/**
2+ * Customize's pieces (routes/workspace/customize/): the card every listing
3+ * shares, the badges that say whether something can be added or is
4+ * connected, the Available-to control that says which agents something
5+ * reaches, the Request button members ask owners with, a request as owners
6+ * and askers see it, filter menus, and section headings.
7+ */
8+import { Check, ChevronDown, Clock, Info, Plus, Send, Users, X } from "lucide-react";
9+import { type ReactNode, useEffect, useId, useState } from "react";
10+import { Link, useFetcher } from "react-router";
11+
12+import type { ConnectorState, InstallRequest, PluginAvailability } from "@g1t/contracts";
13+import { customizePath } from "@g1t/contracts/customize";
14+
15+import type { useDialogFetcher } from "./agents/dialogs";
16+import { ConnectorMark } from "./connectors";
17+import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "./ui";
18+import { Badge, type BadgeTone } from "./ui/badge";
19+import { Button, buttonVariants } from "./ui/button";
20+import { Card } from "./ui/card";
21+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
22+import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
23+import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
24+import { Hint } from "./ui/hint";
25+import { Input } from "./ui/input";
26+import { Textarea } from "./ui/textarea";
27+import { cn } from "../lib/cn";
28+import { CONNECTOR_STATES, type Reach, type ReachOption, type ReachTargets, availabilityWord, kindWord, reachOptions, reachWords } from "../lib/customize";
29+
30+/** A listing's mark: its letters on a steady colour, from its id and name. */
31+export function ListingMark({ id, name, size = 40 }: { id: string; name: string; size?: number }) {
32+ return <ConnectorMark view={{ id, name, provider: null }} size={size} />;
33+}
34+
35+/**
36+ * The card every listing shares: its mark, its name (a link over the whole
37+ * card when it has a page), one line about it, what sits under it
38+ * (`children`), and a last line with where it is from (`meta`) and what
39+ * can be done with it (`action`). `muted` draws one nobody can add yet.
40+ */
41+export function ListingCard({
42+ mark,
43+ title,
44+ to,
45+ description,
46+ badge,
47+ meta,
48+ action,
49+ muted = false,
50+ children,
51+}: {
52+ mark: ReactNode;
53+ title: ReactNode;
54+ to?: string;
55+ description: ReactNode;
56+ badge?: ReactNode;
57+ meta?: ReactNode;
58+ action?: ReactNode;
59+ muted?: boolean;
60+ children?: ReactNode;
61+}) {
62+ return (
63+ <Card asChild className={cn("relative flex flex-col gap-3 p-4 transition-colors", muted ? "border-dashed bg-transparent" : "hover:border-line-strong")}>
64+ <article>
65+ <div className="flex items-start gap-3">
66+ <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>{mark}</span>
67+ <div className="min-w-0 grow">
68+ <div className="flex items-start justify-between gap-2">
69+ <h3 className={cn("min-w-0 truncate text-sm font-semibold", muted && "text-fg-soft")}>
70+ {to ? (
71+ <Link to={to} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
72+ {title}
73+ </Link>
74+ ) : (
75+ title
76+ )}
77+ </h3>
78+ {badge && <span className="relative z-10 flex shrink-0 items-center gap-1.5">{badge}</span>}
79+ </div>
80+ <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{description}</p>
81+ </div>
82+ </div>
83+ {children && <div className="relative z-10">{children}</div>}
84+ <div className="relative z-10 mt-auto flex min-h-8 items-center justify-between gap-3">
85+ <p className="min-w-0 truncate text-xs text-muted">{meta}</p>
86+ {action && <span className="flex shrink-0 items-center gap-2">{action}</span>}
87+ </div>
88+ </article>
89+ </Card>
90+ );
91+}
92+
93+/** Small chips under a card: what a plugin brings, a connector's tools. */
94+export function Chips({ items, muted = false, label }: { items: readonly string[]; muted?: boolean; label?: string }) {
95+ if (items.length === 0) return null;
96+ return (
97+ <ul className="flex flex-wrap gap-1.5" aria-label={label}>
98+ {items.map((item) => (
99+ <li key={item} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}>
100+ {item}
101+ </li>
102+ ))}
103+ </ul>
104+ );
105+}
106+
107+/** A section's heading, with a count or a link at its end, and a line under it. */
108+export function SectionHead({ id, title, aside, children }: { id: string; title: ReactNode; aside?: ReactNode; children?: ReactNode }) {
109+ return (
110+ <div className="mb-3">
111+ <div className="flex items-baseline justify-between gap-3">
112+ <h2 id={id} className="flex items-center gap-2 text-base font-semibold tracking-tight">
113+ {title}
114+ </h2>
115+ {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
116+ </div>
117+ {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
118+ </div>
119+ );
120+}
121+
122+/** The grid listings sit in. */
123+export function ListingGrid({ children }: { children: ReactNode }) {
124+ return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{children}</div>;
125+}
126+
127+/** Discover's banner: what is open to add here, in a wash of the accent. */
128+export function FeaturedBanner({ title, children, action }: { title: ReactNode; children: ReactNode; action?: ReactNode }) {
129+ return (
130+ <Card asChild className="relative overflow-hidden p-5 sm:p-6">
131+ <section aria-label="Featured">
132+ <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_100%_0%,var(--color-accent)_0%,transparent_55%)] opacity-[0.12]" />
133+ <div className="relative flex flex-wrap items-end justify-between gap-4">
134+ <div className="min-w-0 max-w-2xl">
135+ <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
136+ <p className="mt-1 text-sm text-muted">{children}</p>
137+ </div>
138+ {action}
139+ </div>
140+ </section>
141+ </Card>
142+ );
143+}
144+
145+/** A plugin's or a skill's availability as a badge, with why it isn't available here on hover. */
146+export function AvailabilityBadge({ availability }: { availability: PluginAvailability }) {
147+ const { label, tone } = availabilityWord(availability);
148+ const hint =
149+ availability.kind === "unavailable"
150+ ? availability.why
151+ : availability.kind === "soon"
152+ ? "g1t's own, announced and not released yet. Nobody can install it until it is."
153+ : availability.kind === "installed" && !availability.enabled
154+ ? "Installed and switched off: its skills, agents and connectors are kept but reach no agent until an owner switches it on."
155+ : null;
156+ const badge = <Badge tone={tone}>{label}</Badge>;
157+ if (!hint) return badge;
158+ return (
159+ <Hint label={hint}>
160+ <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
161+ {badge}
162+ </span>
163+ </Hint>
164+ );
165+}
166+
167+/** A connector's state as a badge: Connected, Needs sign-in, Needs a key, or Problem with what is wrong on hover. */
168+export function StateBadge({ state, problem }: { state: ConnectorState; problem?: string | null }) {
169+ const { label, tone } = CONNECTOR_STATES[state];
170+ const badge = <Badge tone={tone}>{label}</Badge>;
171+ if (!problem) return badge;
172+ return (
173+ <Hint label={problem}>
174+ <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
175+ {badge}
176+ </span>
177+ </Hint>
178+ );
179+}
180+
181+/** Something to know before connecting: an information mark with the words on hover. */
182+export function Caveat({ text }: { text: string | null | undefined }) {
183+ if (!text) return null;
184+ return (
185+ <Hint label={text}>
186+ <span tabIndex={0} aria-label="Something to know" className="inline-flex rounded-full text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
187+ <Info size={14} />
188+ </span>
189+ </Hint>
190+ );
191+}
192+
193+/**
194+ * A menu that picks one of `options` (a category, a marketplace, a sort),
195+ * as a quiet button naming the choice; `all` is the word for none.
196+ */
197+export function FilterMenu({ label, all, value, options, onChange }: { label: string; all: string; value: string | null; options: readonly { value: string; label: string }[]; onChange: (value: string | null) => void }) {
198+ const chosen = options.find((option) => option.value === value);
199+ return (
200+ <DropdownMenu>
201+ <DropdownMenuTrigger asChild>
202+ <Button type="button" variant={chosen ? "soft" : "outline"} size="sm" aria-label={`${label}: ${chosen?.label ?? all}`}>
203+ <span className="text-muted">{label}</span>
204+ <span className="max-w-40 truncate">{chosen?.label ?? all}</span>
205+ <ChevronDown size={13} className="text-faint" />
206+ </Button>
207+ </DropdownMenuTrigger>
208+ <DropdownMenuContent align="start">
209+ <DropdownMenuLabel>{label}</DropdownMenuLabel>
210+ <DropdownMenuRadioGroup value={value ?? ""} onValueChange={(next) => onChange(next || null)}>
211+ <DropdownMenuRadioItem value="">{all}</DropdownMenuRadioItem>
212+ {options.map((option) => (
213+ <DropdownMenuRadioItem key={option.value} value={option.value}>
214+ {option.label}
215+ </DropdownMenuRadioItem>
216+ ))}
217+ </DropdownMenuRadioGroup>
218+ </DropdownMenuContent>
219+ </DropdownMenu>
220+ );
221+}
222+
223+/**
224+ * Where something reaches, and the control that changes it: a menu of
225+ * every agent, each team and each agent, checked where it already reaches.
226+ * Checking gives it there; unchecking takes it back. Without `targets`
227+ * (someone who may not change it) the line alone shows.
228+ */
229+export function AvailableTo({
230+ reach,
231+ targets,
232+ busy = false,
233+ onChange,
234+ size = "xs",
235+}: {
236+ reach: readonly Reach[];
237+ targets: ReachTargets | null;
238+ busy?: boolean;
239+ /** Give it where `option` says (`checked`), or take it back from there (`option.reach`). */
240+ onChange?: (option: ReachOption, checked: boolean) => void;
241+ size?: "xs" | "sm";
242+}) {
243+ const words = reachWords(reach);
244+ const options = targets ? reachOptions(reach, targets) : [];
245+ if (!targets || !onChange || options.length === 0) {
246+ return (
247+ <span className={cn("inline-flex items-center gap-1.5 text-muted", size === "xs" ? "text-xs" : "text-[0.8125rem]")}>
248+ <Users size={size === "xs" ? 12 : 13} className="shrink-0 text-faint" aria-hidden />
249+ <span className="truncate">{reach.length === 0 ? "Not available to any agent yet" : `Available to ${words}`}</span>
250+ </span>
251+ );
252+ }
253+ return (
254+ <DropdownMenu>
255+ <DropdownMenuTrigger asChild>
256+ <Button type="button" variant="outline" size={size} disabled={busy} className="max-w-full" aria-label={`Available to ${words}; change where it reaches`}>
257+ <Users size={size === "xs" ? 12 : 13} className="text-faint" />
258+ <span className="text-muted">Available to</span>
259+ <span className="truncate">{words}</span>
260+ <ChevronDown size={12} className="text-faint" />
261+ </Button>
262+ </DropdownMenuTrigger>
263+ <DropdownMenuContent align="start" className="min-w-56">
264+ <DropdownMenuLabel>Available to</DropdownMenuLabel>
265+ {options.map((option) => (
266+ <DropdownMenuCheckboxItem key={option.value} checked={option.checked} onCheckedChange={(checked) => onChange(option, checked === true)}>
267+ {option.label}
268+ </DropdownMenuCheckboxItem>
269+ ))}
270+ </DropdownMenuContent>
271+ </DropdownMenu>
272+ );
273+}
274+
275+/** "3 waiting": open requests for a listing, for owners, leading to the requests page. */
276+export function Waiting({ slug, count }: { slug: string; count: number }) {
277+ if (count === 0) return null;
278+ return (
279+ <Link to={customizePath("requests", slug)} className="text-xs text-warn hover:underline">
280+ {count} {count === 1 ? "request" : "requests"}
281+ </Link>
282+ );
283+}
284+
285+/**
286+ * Asking the workspace's owners to add something: a dialog that says what
287+ * is asked for, with an optional note, sent to the requests page. Once
288+ * sent, it says so.
289+ */
290+export function RequestButton({ slug, listing, name, requested, className, size = "sm" }: { slug: string; listing: string; name: string; requested: boolean; className?: string; size?: "xs" | "sm" }) {
291+ const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
292+ const [open, setOpen] = useState(false);
293+ const sent = fetcher.state === "idle" && fetcher.data?.error === null;
294+ useEffect(() => {
295+ if (sent) setOpen(false);
296+ }, [sent]);
297+ if (requested || sent) {
298+ return (
299+ <Hint label="The workspace's owners have your request. You'll hear when they answer.">
300+ <span tabIndex={0} className={cn(buttonVariants({ variant: "outline", size }), "text-muted hover:border-line hover:bg-transparent hover:text-muted", className)}>
301+ <Clock size={13} />
302+ Requested
303+ </span>
304+ </Hint>
305+ );
306+ }
307+ return (
308+ <Dialog open={open} onOpenChange={setOpen}>
309+ <DialogTrigger asChild>
310+ <Button variant="outline" size={size} className={className}>
311+ <Send size={13} />
312+ Request
313+ </Button>
314+ </DialogTrigger>
315+ <DialogContent>
316+ <fetcher.Form method="post" action={customizePath("requests", slug)} className="grid gap-4">
317+ <DialogHeader>
318+ <DialogTitle>Ask to add {name}</DialogTitle>
319+ <DialogDescription>Only the workspace's owners add skills, connectors and plugins. Each of them is notified, and you hear back when one answers.</DialogDescription>
320+ </DialogHeader>
321+ <input type="hidden" name="intent" value="request" />
322+ <input type="hidden" name="listing" value={listing} />
323+ <label className="grid gap-1.5 text-sm">
324+ <span className="font-medium">
325+ Why you want it <span className="font-normal text-faint">(optional)</span>
326+ </span>
327+ <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
328+ </label>
329+ {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
330+ <DialogFooter>
331+ <SubmitButton fetcher={fetcher} pending="Sending…">
332+ <Send size={14} />
333+ Send request
334+ </SubmitButton>
335+ </DialogFooter>
336+ </fetcher.Form>
337+ </DialogContent>
338+ </Dialog>
339+ );
340+}
341+
342+/** A request's status, in a word. */
343+export function RequestStatus({ request }: { request: InstallRequest }) {
344+ const tone: BadgeTone = request.status === "open" ? "warn" : request.status === "done" ? "success" : "neutral";
345+ return <Badge tone={tone}>{request.status === "open" ? "Waiting" : request.status === "done" ? "Added" : "Turned down"}</Badge>;
346+}
347+
348+/**
349+ * One request as a row: what was asked for, by whom, why and when; for an
350+ * owner, while it waits, the way to add it, mark it added, or turn it down.
351+ */
352+export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
353+ const fetcher = useFetcher<{ error: string | null }>();
354+ return (
355+ <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
356+ <div className="min-w-0 grow">
357+ <p className="flex flex-wrap items-center gap-2 text-sm">
358+ <span className="font-medium">{request.name}</span>
359+ <span className="text-xs text-faint">{kindWord(request.kind)}</span>
360+ <RequestStatus request={request} />
361+ </p>
362+ <p className="mt-0.5 text-xs text-muted">
363+ {owner ? `@${request.requested_by} asked ` : "You asked "}
364+ <TimeAgo at={request.requested_at} />
365+ {request.resolved_by && request.status !== "open" && (
366+ <>
367+ {" · "}
368+ {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
369+ </>
370+ )}
371+ </p>
372+ {request.note && <p className="mt-1.5 max-w-xl border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
373+ {fetcher.data?.error && (
374+ <div className="mt-2">
375+ <ErrorText>{fetcher.data.error}</ErrorText>
376+ </div>
377+ )}
378+ </div>
379+ {owner && request.status === "open" && (
380+ <fetcher.Form method="post" action={customizePath("requests", slug)} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
381+ <input type="hidden" name="intent" value="resolve" />
382+ <input type="hidden" name="id" value={request.id} />
383+ {addTo && (
384+ <ButtonLink to={addTo} size="sm">
385+ <Plus size={14} />
386+ {request.kind === "connector" || request.kind === "integration" ? "Connect" : request.kind === "skill" ? "Add" : "Install"}
387+ </ButtonLink>
388+ )}
389+ <SubmitButton fetcher={fetcher} name="status" value="done" variant="outline" size="sm" pending="Saving…">
390+ <Check size={14} />
391+ Mark added
392+ </SubmitButton>
393+ <SubmitButton fetcher={fetcher} name="status" value="declined" variant="outline" size="sm" pending="Saving…">
394+ <X size={14} />
395+ Turn down
396+ </SubmitButton>
397+ </fetcher.Form>
398+ )}
399+ </li>
400+ );
401+}
402+
403+/**
404+ * Owners add a marketplace by address: `owner/repo` on GitHub or a
405+ * repository here, a git address, or a link to its marketplace.json. Posted
406+ * to the Marketplaces page, from wherever the dialog opens.
407+ */
408+export function AddMarketplaceDialog({ slug, dialog }: { slug: string; dialog: ReturnType<typeof useDialogFetcher> }) {
409+ const { fetcher, open, setOpen, error, busy } = dialog;
410+ const id = useId();
411+ return (
412+ <Dialog open={open} onOpenChange={setOpen}>
413+ <DialogContent>
414+ <DialogHeader>
415+ <DialogTitle>Add a marketplace</DialogTitle>
416+ <DialogDescription>
417+ A repository with <code className="font-mono text-xs">.claude-plugin/marketplace.json</code>: on GitHub as owner/repo, a repository here as workspace/name, a git address, or a link to the file itself. Its plugins
418+ show in Discover, in the formats Claude Code reads.
419+ </DialogDescription>
420+ </DialogHeader>
421+ <fetcher.Form method="post" action={customizePath("marketplaces", slug)} className="grid gap-5">
422+ <input type="hidden" name="intent" value="add" />
423+ <Field>
424+ <FieldLabel htmlFor={`${id}-address`}>Address</FieldLabel>
425+ <Input id={`${id}-address`} name="address" placeholder="anthropics/claude-plugins-official" required autoComplete="off" spellCheck={false} className="font-mono" />
426+ </Field>
427+ <Field>
428+ <FieldLabel htmlFor={`${id}-name`}>
429+ Name <span className="font-normal text-faint">(optional)</span>
430+ </FieldLabel>
431+ <Input id={`${id}-name`} name="name" placeholder="As its manifest names it" autoComplete="off" spellCheck={false} />
432+ <FieldDescription>Plugins are named plugin@marketplace, so a name you choose is the one your people see.</FieldDescription>
433+ </Field>
434+ <FieldError>{error}</FieldError>
435+ <DialogFooter>
436+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
437+ Cancel
438+ </Button>
439+ <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "add" }} busy={busy} pending="Reading it…">
440+ Add marketplace
441+ </SubmitButton>
442+ </DialogFooter>
443+ </fetcher.Form>
444+ </DialogContent>
445+ </Dialog>
446+ );
447+}
448+
449+/** What a page says it can't show: the agents service didn't answer. */
450+export function NoAnswer({ what }: { what: string }) {
451+ return (
452+ <Card asChild tone="plain" className="border-dashed px-4 py-8 text-center text-sm text-muted">
453+ <p>{what} can't be shown right now. The agents service didn't answer; reload in a minute.</p>
454+ </Card>
455+ );
456+}
+1−1
741741 tz: string | null;
742742 }) {
743743 if (!decisions || decisions.length === 0) return null;
744− const missing = [...(read && !read.memories ? ["Memory"] : []), ...(read && !read.requests ? ["The Marketplace"] : [])];
744+ const missing = [...(read && !read.memories ? ["Memory"] : []), ...(read && !read.requests ? ["Customize's requests"] : [])];
745745 return (
746746 <Card title="Decisions" hint="Recorded in the workspace's memory, and requests answered">
747747 <Folded
+0−789
1−/**
2− * The Marketplace's pieces (routes/workspace/marketplace/): listings for
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.
8− */
9−import { ArrowRight, Ban, Check, CircleAlert, Clock, Plus, Power, Send, X } from "lucide-react";
10−import { type ReactNode, useEffect, useState } from "react";
11−import { Link, useFetcher, useLocation, useSearchParams } from "react-router";
12−
13−import type { ExtensionManifest, InstallRequest, ListingKind, ListingTier } from "@g1t/contracts";
14−import { LISTING_TIERS } from "@g1t/contracts/marketplace";
15−
16−import { ConnectorMark } from "./connectors";
17−import { Badge } from "./ui/badge";
18−import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
19−import { Hint } from "./ui/hint";
20−import { ButtonLink, ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
21−import { Button, buttonVariants } from "./ui/button";
22−import { Card } from "./ui/card";
23−import {
24− AVAILABILITIES,
25− AVAILABILITY,
26− type Availability,
27− type ExtensionListing,
28− type IntegrationListing,
29− type ListingFilters,
30− type StarterKit,
31− TIERS,
32− addedWord,
33− extensionPath,
34− marketplacePath,
35−} from "../lib/marketplace";
36−import { cn } from "../lib/cn";
37−
38−/** Each tier's colour: Official the accent, Verified green, Community amber, Internal blue. */
39−const TIER_TONE: Record<ListingTier, string> = {
40− official: "bg-accent/12 text-accent",
41− verified: "bg-success/12 text-success",
42− community: "bg-warn/12 text-warn",
43− internal: "bg-info/12 text-info",
44−};
45−
46−/**
47− * Who stands behind a listing: a small square badge in its tier's colour,
48− * with the sentence on hover or focus. Inside a link, `focusable={false}`
49− * keeps it from being a second stop for the keyboard.
50− */
51−export function TierBadge({ tier, focusable = true, className }: { tier: ListingTier; focusable?: boolean; className?: string }) {
52− return (
53− <Hint label={`${TIERS[tier].label}: ${TIERS[tier].about}`}>
54− <span
55− tabIndex={focusable ? 0 : undefined}
56− className={cn(
57− "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",
58− TIER_TONE[tier],
59− className,
60− )}
61− >
62− {TIERS[tier].label}
63− </span>
64− </Hint>
65− );
66−}
67−
68−/**
69− * Whether a listing can be added here: Available, Connected or Installed,
70− * Soon, or Not available here, as an icon and a word (never a button),
71− * with what it means, or why, on hover or focus.
72− */
73−export function AvailabilityBadge({
74− availability,
75− kind,
76− why,
77− off = false,
78− hint,
79− focusable = true,
80− className,
81−}: {
82− availability: Availability;
83− kind: ListingKind;
84− /** Why it isn't available here. */
85− why?: string | null;
86− /** An installed extension that was switched off. */
87− off?: boolean;
88− /** A hint in place of the usual sentence. */
89− hint?: string;
90− /** False inside a link, so it isn't a second stop for the keyboard. */
91− focusable?: boolean;
92− className?: string;
93−}) {
94− const label = availability === "added" ? (off ? "Switched off" : addedWord(kind)) : AVAILABILITY[availability].label;
95− const about =
96− hint ??
97− (availability === "unavailable" && why
98− ? why
99− : availability === "added"
100− ? off
101− ? "Installed, and switched off: its token doesn't work and its page doesn't load until an owner switches it on."
102− : `This workspace has it ${kind === "integration" ? "connected" : "installed"}.`
103− : AVAILABILITY[availability].about);
104− const icon =
105− availability === "available" ? (
106− <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-success" />
107− ) : availability === "added" ? (
108− off ? (
109− <Power size={12} className="shrink-0" />
110− ) : (
111− <Check size={13} className="shrink-0" />
112− )
113− ) : availability === "soon" ? (
114− <Clock size={12} className="shrink-0" />
115− ) : (
116− <Ban size={12} className="shrink-0" />
117− );
118− return (
119− <Hint label={about}>
120− <span
121− tabIndex={focusable ? 0 : undefined}
122− className={cn(
123− "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",
124− availability === "available" ? "text-fg-soft" : availability === "added" ? (off ? "text-warn" : "text-success") : "text-muted",
125− className,
126− )}
127− >
128− {icon}
129− <span className="truncate">{label}</span>
130− </span>
131− </Hint>
132− );
133−}
134−
135−/** "Soon", on what is planned and not built. */
136−export function ComingBadge() {
137− return <AvailabilityBadge availability="soon" kind="extension" />;
138−}
139−
140−/**
141− * What the labels mean: who builds what (the four tiers) and whether a
142− * listing can be added here. On Discover, and at the end of each tab.
143− */
144−export function ListingLegend({ id = "legend", kind }: { id?: string; kind?: ListingKind }) {
145− return (
146− <section aria-labelledby={id} className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
147− <h2 id={id} className="text-base font-semibold tracking-tight">
148− Who builds what
149− </h2>
150− <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>
151− <ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
152− {LISTING_TIERS.map((tier) => (
153− <Card asChild key={tier} tone="bg" className="p-4">
154− <li>
155− <TierBadge tier={tier} />
156− <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p>
157− </li>
158− </Card>
159− ))}
160− </ul>
161− <h3 className="mt-6 text-sm font-semibold">Whether you can add it</h3>
162− <ul className="mt-3 grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
163− {AVAILABILITIES.map((availability) => (
164− <li key={availability} className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[0.8125rem] text-muted">
165− <span className="w-36 shrink-0">
166− <AvailabilityBadge availability={availability} kind={kind ?? "integration"} />
167− </span>
168− <span className="min-w-0 basis-56 grow">
169− {availability === "added"
170− ? kind === "extension"
171− ? "Installed in this workspace."
172− : kind === "integration"
173− ? "Connected for this workspace."
174− : "Connected or installed in this workspace."
175− : availability === "unavailable"
176− ? "This workspace or this g1t lacks something it needs, and the listing says what."
177− : AVAILABILITY[availability].about}
178− </span>
179− </li>
180− ))}
181− </ul>
182− </section>
183− );
184−}
185−
186−/** A tier's section while nothing in it is listed: said plainly, so the structure shows. */
187−export function TierEmpty({ tier, kind, filtered = false }: { tier: ListingTier; kind: ListingKind; filtered?: boolean }) {
188− return (
189− <Card asChild tone="plain" className="border-dashed px-4 py-4 text-sm text-muted">
190− <p>
191− <span>{filtered && tier === "official" ? TIERS.official.none[kind] : filtered ? `None match. ${TIERS[tier].none[kind]}` : TIERS[tier].none[kind]}</span>
192− </p>
193− </Card>
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−
301−/** A section's heading, with a count or a link at its end. */
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";
304− return (
305− <div className="mb-3">
306− <div className="flex items-baseline justify-between gap-3">
307− <Heading id={id} className={cn("flex items-center gap-2 font-semibold tracking-tight", sub ? "text-sm" : "text-base")}>
308− {title}
309− </Heading>
310− {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
311− </div>
312− {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
313− </div>
314− );
315−}
316−
317−/** A link at the end of a section's heading. */
318−export function SeeAll({ to, children }: { to: string; children: ReactNode }) {
319− return (
320− <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg">
321− {children}
322− <ArrowRight size={13} />
323− </Link>
324− );
325−}
326−
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−
330−/**
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.
334− */
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−}) {
350− const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
351− const [open, setOpen] = useState(false);
352− const sent = fetcher.state === "idle" && fetcher.data?.error === null;
353− useEffect(() => {
354− if (sent) setOpen(false);
355− }, [sent]);
356− if (requested || sent) {
357− return (
358− <Hint label="The workspace's owners have your request. You'll hear when they answer.">
359− <span tabIndex={0} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-muted hover:border-line hover:bg-transparent hover:text-muted", className)}>
360− <Clock size={13} />
361− Requested
362− </span>
363− </Hint>
364− );
365− }
366− return (
367− <Dialog open={open} onOpenChange={setOpen}>
368− <DialogTrigger asChild>
369− <Button variant="outline" size="sm" className={className}>
370− <Send size={13} />
371− Request
372− </Button>
373− </DialogTrigger>
374− <DialogContent>
375− <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
376− <DialogHeader>
377− <DialogTitle>Ask to add {name}</DialogTitle>
378− <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
379− </DialogHeader>
380− {about && (
381− <Card tone="bg" radius="lg" className="flex items-center gap-3 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− </Card>
395− )}
396− <input type="hidden" name="intent" value="request" />
397− <input type="hidden" name="listing" value={listing} />
398− <label className="grid gap-1.5 text-sm">
399− <span className="font-medium">
400− Why you want it <span className="font-normal text-faint">(optional)</span>
401− </span>
402− <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
403− </label>
404− {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
405− <DialogFooter>
406− <SubmitButton fetcher={fetcher} pending="Sending…">
407− <Send size={14} />
408− Send request
409− </SubmitButton>
410− </DialogFooter>
411− </fetcher.Form>
412− </DialogContent>
413− </Dialog>
414− );
415−}
416−
417−/** "3 waiting": open requests for a listing, for owners. */
418−function Waiting({ slug, count }: { slug: string; count: number }) {
419− if (count === 0) return null;
420− return (
421− <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
422− {count} {count === 1 ? "request" : "requests"}
423− </Link>
424− );
425−}
426−
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− <ButtonLink to={listing.href} variant="outline" size="sm" className={className}>
437− Connect yours
438− </ButtonLink>
439− ) : null;
440− }
441− if (listing.connected) {
442− return listing.href ? (
443− <ButtonLink to={listing.href} variant="outline" size="sm" className={className}>
444− Manage
445− </ButtonLink>
446− ) : null;
447− }
448− if (owner) {
449− return listing.href ? (
450− <ButtonLink to={listing.href} size="sm" className={className}>
451− <Plus size={14} />
452− Connect
453− </ButtonLink>
454− ) : null;
455− }
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";
471−}
472−
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− */
492−export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
493− const { view, connected } = listing;
494− const muted = inert(listing.availability);
495− return (
496− <ListingShell to={listing.path} name={view.name} muted={muted}>
497− <div className="flex items-start gap-3">
498− <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
499− <ConnectorMark view={view} size={40} />
500− </span>
501− <div className="min-w-0 grow">
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>
508− <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
509− </div>
510− </div>
511− {view.capabilities.length > 0 && (
512− <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label={muted ? "What it will do" : "What it does"}>
513− {view.capabilities.map((capability) => (
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")}>
515− {capability}
516− </li>
517− ))}
518− </ul>
519− )}
520− <div className="relative mt-4 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3">
521− <span className="flex min-w-0 items-center gap-1.5 text-xs">
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>
527− </span>
528− </Hint>
529− ) : (
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− />
542− )}
543− {listing.scope === "personal" && <span className="truncate text-faint">· Each person's own</span>}
544− {owner && listing.waiting > 0 && (
545− <>
546− <span className="text-faint">·</span>
547− <Waiting slug={slug} count={listing.waiting} />
548− </>
549− )}
550− </span>
551− <IntegrationAction listing={listing} slug={slug} owner={owner} />
552− </div>
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>
578− );
579−}
580−
581−/** An extension's mark: its initials on a steady colour, until listings bring their own icon. */
582−export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) {
583− return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
584−}
585−
586−/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before, or once installed. */
587−export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
588− const fetcher = useFetcher<{ error: string | null }>();
589− const { manifest } = listing;
590− if (listing.availability !== "available") return null;
591− if (!owner) {
592− return (
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− />
601− );
602− }
603− return (
604− <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
605− <input type="hidden" name="intent" value="install" />
606− <input type="hidden" name="extension" value={manifest.id} />
607− <SubmitButton fetcher={fetcher} size="sm" className={className} pending="Installing…">
608− <Plus size={14} />
609− Install
610− </SubmitButton>
611− </fetcher.Form>
612− );
613−}
614−
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−
635−/** Where an extension's data goes, in a few words for a card. */
636−function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
637− // "the CRM you connect" reads, on a card, as "Works with your CRM".
638− if (manifest.bridges) return `Works with ${manifest.bridges.replace(/^the (.+) you connect$/, "your $1")}`;
639− return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`;
640−}
641−
642−/** An extension as a card: its mark, publisher and tier, what it does, where its data goes, and whether it can be added. */
643−export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
644− const { manifest } = listing;
645− const muted = inert(listing.availability);
646− return (
647− <ListingShell to={extensionPath(slug, manifest.id)} name={manifest.name} muted={muted}>
648− <div className="flex items-start gap-3">
649− <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
650− <ExtensionMark manifest={manifest} />
651− </span>
652− <div className="min-w-0 grow">
653− <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{manifest.name}</h3>
654− <p className="truncate text-xs text-muted">
655− by {manifest.publisher.name} · {manifest.category}
656− </p>
657− </div>
658− <span className="relative shrink-0">
659− <TierBadge tier={listing.tier} />
660− </span>
661− </div>
662− <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
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>
674− <ExtensionAction listing={listing} slug={slug} owner={owner} />
675− </div>
676− </ListingShell>
677− );
678−}
679−
680−/** A request's status, in a word. */
681−export function RequestStatus({ request }: { request: InstallRequest }) {
682− if (request.status === "open") return <Badge tone="warn">Waiting</Badge>;
683− if (request.status === "done") return <Badge tone="success">Added</Badge>;
684− return <Badge tone="neutral">Turned down</Badge>;
685−}
686−
687−/**
688− * One request as a row: what was asked for, by whom, why and when; for an
689− * owner, while it waits, the way to add it, mark it added, or turn it down.
690− */
691−export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
692− const fetcher = useFetcher<{ error: string | null }>();
693− const action = marketplacePath(slug, "requests");
694− return (
695− <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
696− <div className="min-w-0 grow">
697− <p className="flex flex-wrap items-center gap-2 text-sm">
698− <span className="font-medium">{request.name}</span>
699− <span className="text-xs text-faint">{request.kind === "extension" ? "Extension" : "Integration"}</span>
700− <RequestStatus request={request} />
701− </p>
702− <p className="mt-0.5 text-xs text-muted">
703− {owner ? `@${request.requested_by} asked ` : "You asked "}
704− <TimeAgo at={request.requested_at} />
705− {request.resolved_by && request.status !== "open" && (
706− <>
707− {" · "}
708− {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
709− </>
710− )}
711− </p>
712− {request.note && <p className="mt-1.5 max-w-xl rounded-md border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
713− {fetcher.data?.error && (
714− <div className="mt-2">
715− <ErrorText>{fetcher.data.error}</ErrorText>
716− </div>
717− )}
718− </div>
719− {owner && request.status === "open" && (
720− <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
721− <input type="hidden" name="intent" value="resolve" />
722− <input type="hidden" name="id" value={request.id} />
723− {addTo && (
724− <ButtonLink to={addTo} size="sm">
725− <Plus size={14} />
726− {request.kind === "extension" ? "Install" : "Connect"}
727− </ButtonLink>
728− )}
729− <SubmitButton fetcher={fetcher} name="status" value="done" variant="outline" size="sm" pending="Saving…">
730− <Check size={14} />
731− Mark added
732− </SubmitButton>
733− <SubmitButton fetcher={fetcher} name="status" value="declined" variant="outline" size="sm" pending="Saving…">
734− <X size={14} />
735− Turn down
736− </SubmitButton>
737− </fetcher.Form>
738− )}
739− </li>
740− );
741−}
742−
743−/**
744− * A starter kit as a card: the extensions it brings, overlapped, its name
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.
748− */
749−export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) {
750− const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available");
751− const tiers = [...new Set(extensions.map((extension) => extension.publisher.tier))];
752− return (
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")}>
756− {extensions.map((extension, index) => (
757− <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-bg", index > 0 && "-ml-2")}>
758− <ExtensionMark manifest={extension} size={30} />
759− </span>
760− ))}
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− />
786− </div>
787− </article>
788− );
789−}
+3−3
1212 Moon,
1313 Settings,
1414 Smile,
15− Store,
15+ SlidersHorizontal,
1616 Sun,
1717 } from "lucide-react";
1818 import { Dialog as Primitive } from "radix-ui";
257257
258258 /**
259259 * More, from the bottom bar: a flat panel standing on it, with g1t's
260− * mark at its top and a grid of big tiles: all apps, the Marketplace,
260+ * mark at its top and a grid of big tiles: all apps, Customize,
261261 * your pinned apps, Artifacts, People, Workspace, and "You and
262262 * help", which opens your account's sheet.
263263 */
283283 </div>
284284 <div className="grid grid-cols-4 gap-1.5">
285285 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
286− <MoreTile to={`/${slug}/-/marketplace`} icon={<Store size={20} />} label="Marketplace" />
286+ <MoreTile to={`/${slug}/-/customize`} icon={<SlidersHorizontal size={20} />} label="Customize" />
287287 {shown.map((app) => (
288288 <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
289289 ))}
+12−10
5555 Scale,
5656 Smile,
5757 Sparkles,
58− Store,
58+ SlidersHorizontal,
5959 House,
6060 Ticket,
6161 TrendingUp,
21042104 artifacts: "Artifacts",
21052105 home: "Home",
21062106 apps: "Apps",
2107− marketplace: "Marketplace",
2107+ customize: "Customize",
21082108 "code-access": "Code access",
21092109 overview: "Overview",
21102110 workspace: "Workspace",
22082208 label: third === "settings" ? "General" : (SECTIONS[third] ?? third),
22092209 to: page,
22102210 });
2211− // The Marketplace names its tab (Marketplace / Extensions), Agents its templates, and Members its invitations.
2211+ // Customize names its tab (Customize / Plugins), Agents its templates, and Members its invitations.
22122212 const tabs: Record<string, string> =
2213− third === "marketplace"
2213+ third === "customize"
22142214 ? {
2215− integrations: "Integrations",
2216− extensions: "Extensions",
2215+ skills: "Skills",
2216+ connectors: "Connectors",
2217+ plugins: "Plugins",
2218+ marketplaces: "Marketplaces",
22172219 requests: "Requests",
22182220 }
22192221 : third === "agents"
24242426 icon: <LayoutGrid size={15} />,
24252427 },
24262428 {
2427− label: "Marketplace",
2428− hint: "Extensions and integrations to add",
2429− to: `/${ws.slug}/-/marketplace`,
2430− icon: <Store size={15} />,
2429+ label: "Customize",
2430+ hint: "Skills, connectors and plugins",
2431+ to: `/${ws.slug}/-/customize`,
2432+ icon: <SlidersHorizontal size={15} />,
24312433 },
24322434 {
24332435 label: "Agent templates",
+34−1
1−import { ChevronRight } from "lucide-react";
1+import { Check, ChevronRight, Circle } from "lucide-react";
22 import { DropdownMenu as Primitive } from "radix-ui";
33 import type { ComponentProps } from "react";
44
121121 </Primitive.Portal>
122122 );
123123 }
124+
125+const CHOICE =
126+ "relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-fg/90 outline-none select-none pointer-coarse:min-h-10 data-highlighted:bg-line data-highlighted:text-fg data-disabled:opacity-50 [&_svg]:shrink-0";
127+
128+/** A row that is on or off, with a check when it is on. */
129+export function DropdownMenuCheckboxItem({ className, children, ...props }: ComponentProps<typeof Primitive.CheckboxItem>) {
130+ return (
131+ <Primitive.CheckboxItem className={cn(CHOICE, className)} {...props}>
132+ <span className="absolute left-2 flex size-4 items-center justify-center">
133+ <Primitive.ItemIndicator>
134+ <Check size={14} />
135+ </Primitive.ItemIndicator>
136+ </span>
137+ {children}
138+ </Primitive.CheckboxItem>
139+ );
140+}
141+
142+export const DropdownMenuRadioGroup = Primitive.RadioGroup;
143+
144+/** One of a group's choices, with a dot on the chosen one. */
145+export function DropdownMenuRadioItem({ className, children, ...props }: ComponentProps<typeof Primitive.RadioItem>) {
146+ return (
147+ <Primitive.RadioItem className={cn(CHOICE, className)} {...props}>
148+ <span className="absolute left-2 flex size-4 items-center justify-center">
149+ <Primitive.ItemIndicator>
150+ <Circle size={8} className="fill-current" />
151+ </Primitive.ItemIndicator>
152+ </span>
153+ {children}
154+ </Primitive.RadioItem>
155+ );
156+}
+1−1
3636 assert.equal(owner?.action, "connect");
3737 assert.equal(owner?.href, "/acme/-/integrations/trackers?add=linear#add");
3838 const member = integrationHint("acme", { id: "linear", why: "Reads issues." }, false);
39− assert.deepEqual(member && { action: member.action, href: member.href }, { action: "ask", href: "/acme/-/marketplace/integrations/linear" });
39+ assert.deepEqual(member && { action: member.action, href: member.href }, { action: "ask", href: "/acme/-/customize/connectors?view=discover&directory=linear" });
4040 assert.equal(integrationHint("acme", { id: "box", why: "" }, true)?.action, "soon");
4141 assert.equal(integrationHint("acme", { id: "nope", why: "" }, true), null);
4242 });
+2−2
6363 why: string;
6464 /** `connect` (an owner sets it up), `ask` (anyone else asks an owner), `soon` (nobody can yet). */
6565 action: "connect" | "ask" | "soon";
66− /** Its page in the Marketplace, which connects it or asks for it. */
66+ /** Where it is connected, or where anyone else asks for it: Customize's Connectors. */
6767 href: string;
6868 };
6969
7070 export function integrationHint(slug: string, suggestion: { id: string; why: string }, owner: boolean): IntegrationHint | null {
7171 const connector = connectorById(suggestion.id);
7272 if (!connector) return null;
73− const href = `/${slug}/-/marketplace/integrations/${encodeURIComponent(connector.id)}`;
73+ const href = `/${slug}/-/customize/connectors?view=discover&directory=${encodeURIComponent(connector.id)}`;
7474 const action = connector.status === "soon" ? "soon" : owner ? "connect" : "ask";
7575 return { id: connector.id, name: connector.name, why: suggestion.why, action, href: action === "connect" && connector.href?.workspace ? connectorPath(connector.href.workspace, slug) : href };
7676 }
+21−4
2121 writeDock,
2222 } from "./apps.ts";
2323
24−test("only apps installed from the Marketplace are pinned; built-in apps and their pages are not", () => {
24+test("only integrations are pinned; built-in apps, their pages, plugins and connectors are not", () => {
2525 for (const app of BUILTINS) assert.equal(isPinnable(app.key), false, app.key);
2626 for (const page of ["projects", "packages", "security", "context", "memory", "teams", "usage", "gateway", "integrations", "audit"]) {
2727 assert.equal(isPinnable(page), false, page);
2929 assert.equal(isPinnable("int-sentry"), true);
3030 assert.equal(isPinnable("int-slack"), false, "Slack can't be connected yet");
3131 assert.equal(isPinnable("int-mcp"), false, "MCP clients are each person's, not the workspace's");
32− assert.equal(isPinnable("ext-support"), false, "no extensions yet");
32+ assert.equal(isPinnable("ext-support"), false, "extensions became plugins, which aren't pinned");
33+ assert.equal(isPinnable("plg-pli_01"), false);
34+ assert.equal(isPinnable("con-con_01"), false);
3335 assert.equal(isPinnable("int-Sentry"), false);
3436 });
3537
4345 assert.ok(integrationAppKey("anthropic-endpoint").length <= 32);
4446 });
4547
46−test("installed apps are what the workspace connected, opening where each is managed", () => {
48+test("installed apps are the plugins that are on, the connectors, and what the workspace connected, opening where each is managed", () => {
4749 const apps = installedApps({ sentry: { manage: "/acme/-/integrations/alerts" }, github: { manage: null }, nonsense: { manage: null } });
4850 assert.deepEqual(apps.map((app) => app.key), ["int-github", "int-sentry"]);
4951 assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme");
5052 assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/integrations/alerts");
5153 assert.deepEqual(installedApps({}), []);
54+ const pdf = { id: "pli_1", ref: "pdf@anthropic-agent-skills", name: "pdf", display_name: null, description: "PDFs", marketplace: "anthropic-agent-skills", enabled: true };
55+ const off = { ...pdf, id: "pli_2", ref: "xlsx@anthropic-agent-skills", name: "xlsx", enabled: false };
56+ const notion = { id: "con_1", title: "Notion", about: null, url: "https://mcp.notion.com/mcp" };
57+ const all = installedApps({ sentry: { manage: null } }, [pdf, off], [notion]);
58+ assert.deepEqual(
59+ all.map((app) => [app.key, app.from, app.pinnable]),
60+ [
61+ ["plg-pli_1", "anthropic-agent-skills", false],
62+ ["con-con_1", "Connector", false],
63+ ["int-sentry", "g1t", true],
64+ ],
65+ );
66+ assert.equal(all[0]!.path("acme"), "/acme/-/customize/plugins/pdf%40anthropic-agent-skills");
67+ assert.equal(all[1]!.path("acme"), "/acme/-/customize/connectors/con_1");
68+ assert.equal(all[1]!.about, "https://mcp.notion.com/mcp");
5269 });
5370
5471 test("pins are kept per workspace, in the order they were pinned", () => {
6885 assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS);
6986 });
7087
71−test("pins of built-in pages from before Apps were the Marketplace's are dropped", () => {
88+test("pins of built-in pages from before Apps were Customize's are dropped", () => {
7289 assert.deepEqual(pinsFrom(["usage", "int-sentry", "projects", "today", "int-sentry", "teams", "int-linear"]), ["int-sentry", "int-linear"]);
7390 assert.deepEqual(pinsIn("acme:projects.usage.teams", "acme"), []);
7491 assert.deepEqual(pinsFrom([]), []);
+64−42
Loading the diff…
+1−1
Loading the diff…
+82−0
Loading the diff…
+309−0
Loading the diff…
+485−0
Loading the diff…
+6−6
Loading the diff…
+1−1
Loading the diff…
+1−1
Loading the diff…
+6−6
Loading the diff…
+0−13
Loading the diff…
+0−166
Loading the diff…
+0−360
Loading the diff…
+2−1
Loading the diff…
+3−1
Loading the diff…
+36−6
Loading the diff…
+20−14
Loading the diff…
+80−25
Loading the diff…
+15−6
Loading the diff…
+0−306
Loading the diff…
+10−4
Loading the diff…
+69−9
Loading the diff…
+19−19
Loading the diff…
+31−0
Loading the diff…
+163−0
Loading the diff…
+217−0
Loading the diff…
+465−0
Loading the diff…
+11−0
Loading the diff…
+216−0
Loading the diff…
+189−0
Loading the diff…
+434−0
Loading the diff…
+302−0
Loading the diff…
+108−0
Loading the diff…
+455−0
Loading the diff…
+0−123
Loading the diff…
+0−170
Loading the diff…
+0−97
Loading the diff…
+0−185
Loading the diff…
+0−156
Loading the diff…
+0−113
Loading the diff…
+0−106
Loading the diff…