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

Customize replaces the Marketplace on the site and the phone: three tabs, Skills, Connectors and Plugins, each with Yours and Discover, a search and the owners' Add menu, all kept in the address. Skills' Yours is the library (drafts, each skill with an inline Available to control that attaches it to every agent, a team or one, g1t's foundational skills, and the repository it follows); its Discover lists every skill in every marketplace's plugins, grouped by marketplace, and Add installs the plugin that brings one. Connectors' Yours is the workspace's MCP servers with their state, tools, where they reach and what an owner does with them (sign in or out, a key, list tools again, remove), beside the integrations the workspace has connected; its Discover is the directory, MCP servers first and connected in a step (the browser signs in, a key is pasted, or neither), then g1t's integrations, each person's own, and what is coming; a connector has a page, a connect page by directory entry or address, and the sign-in's return lands on it. Plugins' Yours is what the workspace installed with what each brought, its switch, Update when the marketplace moved on, and Uninstall; its Discover is every plugin, narrowed by category and marketplace and sorted by marketplace or name, with g1t's announced ones muted as Soon; a plugin's page renders its README and lists its skills (given to agents from there), its subagents (Hire opens the new-agent form filled from one), its servers and what only Claude Code uses. Marketplaces have a page; requests moved under Customize and name plugins, skills and connectors. Apps lists installed plugins and connectors beside integrations, ⌘K, the Apps page, the phone's More and the breadcrumbs say Customize, and the Marketplace's and the skill library's old addresses redirect. An agent's Abilities tab shows the connectors that reach it as Connectors, with per-tool levels, and keeps Add an MCP server under Only this agent; Templates lists the agents installed plugins bring; an agent's Skills tab says which plugin a skill came from. The phone's deep links open Customize's tabs.

syntaqxcommitted Parenta1cd67eBrowse 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…

Comments

Sign in to comment.