Merge branch 'worktree-agent-a432a6fe93007b723' into customize
57 files+4589−27770/57 viewed
| 1 | + | import { pluginRef } from "@g1t/contracts/claude-plugin"; | |
| 1 | 2 | import { connectorPath, connectorsFor } from "@g1t/contracts/connectors"; | |
| 2 | 3 | import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize"; | |
| 3 | 4 | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| ⋯ | |||
| 8 | 9 | * (packages/contracts/src/customize.ts). What is listed comes from the | |
| 9 | 10 | * catalogs every g1t ships with; a workspace's own library, connections | |
| 10 | 11 | * 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. | |
| 12 | 15 | */ | |
| 13 | 16 | ||
| 14 | 17 | export type CustomizeItem = { | |
| ⋯ | |||
| 42 | 45 | description: connector.description, | |
| 43 | 46 | status: connector.status, | |
| 44 | 47 | 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)}`, | |
| 46 | 49 | })); | |
| 47 | 50 | } | |
| 48 | 51 | return FIRST_PARTY_EXTENSIONS.map((extension) => ({ | |
| ⋯ | |||
| 51 | 54 | description: extension.tagline, | |
| 52 | 55 | status: extension.status, | |
| 53 | 56 | group: `${extension.publisher.name} · ${extension.category}`, | |
| 54 | − | sitePath: `${fallback}/${encodeURIComponent(extension.id)}`, | |
| 57 | + | sitePath: customizePath("plugins", workspace, encodeURIComponent(pluginRef(extension.id, "g1t"))), | |
| 55 | 58 | })); | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 25 | 25 | assert.equal(routeFor("/acme/rocket/issues/new"), null); | |
| 26 | 26 | }); | |
| 27 | 27 | ||
| 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", () => { | |
| 29 | 37 | assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme"); | |
| 30 | 38 | 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"); | |
| 31 | 40 | }); | |
| 32 | 41 | ||
| 33 | 42 | test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => { |
| 11 | 11 | /** Where the app's OAuth sign-in comes back to. */ | |
| 12 | 12 | export const OAUTH_CALLBACK_PATH = "/oauth/callback"; | |
| 13 | 13 | ||
| 14 | + | /** Customize's tabs, each a screen of the app's Customize. */ | |
| 15 | + | const CUSTOMIZE_TABS = new Set(["skills", "connectors", "plugins"]); | |
| 16 | + | ||
| 14 | 17 | /** Path segments a workspace or account can never be named (the site's own pages). */ | |
| 15 | 18 | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); | |
| 16 | 19 | ||
| ⋯ | |||
| 39 | 42 | return null; | |
| 40 | 43 | } | |
| 41 | 44 | 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)) { | |
| 43 | 55 | return `/customize?${workspace}`; | |
| 44 | 56 | } | |
| 45 | 57 | return null; | |
| 169 | 169 | <SideLink to={`/${slug}/-/agents/templates`} icon={<LayoutTemplate size={15} className="text-faint" />}> | |
| 170 | 170 | Templates | |
| 171 | 171 | </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" />}> | |
| 173 | 173 | Skills | |
| 174 | 174 | </SideLink> | |
| 175 | 175 | {code && ( |
| 2 | 2 | import { useId } from "react"; | |
| 3 | 3 | import { Link, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { Ability, AbilityLevel, AbilitySection, AbilitySource, McpServer } from "@g1t/contracts"; | |
| 5 | + | import type { Ability, AbilityLevel, AbilitySection, AbilitySource, AgentConnector, McpServer } from "@g1t/contracts"; | |
| 6 | 6 | import { ABILITY_LEVEL_LABELS } from "@g1t/contracts/abilities"; | |
| 7 | + | import { customizePath } from "@g1t/contracts/customize"; | |
| 7 | 8 | ||
| 8 | 9 | import { cn } from "../../lib/cn"; | |
| 10 | + | import { connectorPagePath } from "../../lib/customize"; | |
| 11 | + | import { StateBadge } from "../customize"; | |
| 9 | 12 | import { ButtonLink, SubmitButton } from "../ui"; | |
| 10 | 13 | import { Button } from "../ui/button"; | |
| 11 | 14 | import { Badge } from "../ui/badge"; | |
| ⋯ | |||
| 131 | 134 | ); | |
| 132 | 135 | } | |
| 133 | 136 | ||
| 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 | + | */ | |
| 135 | 143 | export function SourceBlock({ | |
| 136 | 144 | section, | |
| 137 | 145 | source, | |
| ⋯ | |||
| 140 | 148 | isOwner, | |
| 141 | 149 | slug, | |
| 142 | 150 | fetcher, | |
| 151 | + | connector = null, | |
| 143 | 152 | }: { | |
| 144 | 153 | section: AbilitySection; | |
| 145 | 154 | source: AbilitySource; | |
| ⋯ | |||
| 148 | 157 | isOwner: boolean; | |
| 149 | 158 | slug: string; | |
| 150 | 159 | fetcher: Fetcher; | |
| 160 | + | connector?: AgentConnector | null; | |
| 151 | 161 | }) { | |
| 152 | 162 | const integration = section.group === "integration"; | |
| 153 | 163 | 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)}`; | |
| 155 | 166 | return ( | |
| 156 | 167 | <article className="overflow-hidden rounded-xl border border-line bg-surface" aria-labelledby={`source-${source.id}`}> | |
| 157 | 168 | {(integration || mcp) && ( | |
| ⋯ | |||
| 161 | 172 | {source.name} | |
| 162 | 173 | </h3> | |
| 163 | 174 | {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 && ( | |
| 165 | 178 | <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"> | |
| 166 | 179 | {source.href} | |
| 167 | 180 | <ExternalLink size={11} aria-hidden /> | |
| ⋯ | |||
| 175 | 188 | Manage | |
| 176 | 189 | </Link> | |
| 177 | 190 | )} | |
| 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 && ( | |
| 179 | 197 | <> | |
| 180 | 198 | <fetcher.Form method="post"> | |
| 181 | 199 | <input type="hidden" name="intent" value="mcp_refresh" /> | |
| 16 | 16 | import { SelectField } from "../ui/select"; | |
| 17 | 17 | import { useDialogFetcher } from "./dialogs"; | |
| 18 | 18 | ||
| 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. */ | |
| 20 | 20 | export function skillsPath(slug: string, name?: string, rest = ""): string { | |
| 21 | + | if (!name && !rest) return `/${slug}/-/customize/skills`; | |
| 21 | 22 | return `/${slug}/-/agents/skills${name ? `/${encodeURIComponent(name)}` : ""}${rest}`; | |
| 22 | 23 | } | |
| 23 | 24 |
| 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"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useFetchers } from "react-router"; | |
| 4 | 4 | ||
| 5 | + | import { customizePath } from "@g1t/contracts/customize"; | |
| 6 | + | ||
| 5 | 7 | import { ConnectorMark } from "./connectors"; | |
| 6 | − | import { TierBadge } from "./marketplace"; | |
| 7 | 8 | import { Card } from "./ui/card"; | |
| 8 | 9 | import { Skeleton } from "./ui/skeleton"; | |
| 9 | 10 | import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps"; | |
| ⋯ | |||
| 25 | 26 | return icons[key]; | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 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. */ | |
| 29 | 30 | export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) { | |
| 30 | 31 | return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />; | |
| 31 | 32 | } | |
| ⋯ | |||
| 97 | 98 | </span> | |
| 98 | 99 | {card ? ( | |
| 99 | 100 | <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"> | |
| 101 | 102 | <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> | |
| 103 | 104 | </span> | |
| 104 | 105 | <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span> | |
| 105 | 106 | </span> | |
| 106 | 107 | ) : ( | |
| 107 | 108 | <span className="flex w-full min-w-0 flex-col items-center gap-1"> | |
| 108 | 109 | <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> | |
| 110 | 111 | </span> | |
| 111 | 112 | )} | |
| 112 | 113 | </> | |
| ⋯ | |||
| 126 | 127 | <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}> | |
| 127 | 128 | {body} | |
| 128 | 129 | </Link> | |
| 129 | − | {app.usable && ( | |
| 130 | + | {app.usable && app.pinnable && ( | |
| 130 | 131 | <Hint label={pinned ? "Unpin from your rail" : "Pin to your rail"}> | |
| 131 | 132 | <button | |
| 132 | 133 | type="button" | |
| ⋯ | |||
| 147 | 148 | ); | |
| 148 | 149 | } | |
| 149 | 150 | ||
| 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. */ | |
| 151 | 152 | export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) { | |
| 152 | 153 | return ( | |
| 153 | 154 | <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")}> | |
| 154 | 155 | <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} /> | |
| 156 | 157 | </span> | |
| 157 | 158 | <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 | |
| 162 | 163 | </Link> | |
| 163 | 164 | </div> | |
| 164 | 165 | ); | |
| ⋯ | |||
| 167 | 168 | /** | |
| 168 | 169 | * The Apps launcher's body: a search, every app the workspace installed | |
| 169 | 170 | * 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. | |
| 171 | 172 | */ | |
| 172 | 173 | export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) { | |
| 173 | 174 | const [query, setQuery] = useState(""); | |
| ⋯ | |||
| 234 | 235 | All apps | |
| 235 | 236 | </Link> | |
| 236 | 237 | <Link | |
| 237 | − | to={`/${slug}/-/marketplace`} | |
| 238 | + | to={customizePath("", slug)} | |
| 238 | 239 | onClick={onClose} | |
| 239 | 240 | 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" | |
| 240 | 241 | > | |
| 241 | − | <Store size={14} /> | |
| 242 | − | Marketplace | |
| 242 | + | <SlidersHorizontal size={14} /> | |
| 243 | + | Customize | |
| 243 | 244 | </Link> | |
| 244 | 245 | </div> | |
| 245 | 246 | </div> | |
| 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 | + | } |
| 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 | + | } |
| 741 | 741 | tz: string | null; | |
| 742 | 742 | }) { | |
| 743 | 743 | 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"] : [])]; | |
| 745 | 745 | return ( | |
| 746 | 746 | <Card title="Decisions" hint="Recorded in the workspace's memory, and requests answered"> | |
| 747 | 747 | <Folded |
| 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 | − | } |
| 12 | 12 | Moon, | |
| 13 | 13 | Settings, | |
| 14 | 14 | Smile, | |
| 15 | − | Store, | |
| 15 | + | SlidersHorizontal, | |
| 16 | 16 | Sun, | |
| 17 | 17 | } from "lucide-react"; | |
| 18 | 18 | import { Dialog as Primitive } from "radix-ui"; | |
| ⋯ | |||
| 257 | 257 | ||
| 258 | 258 | /** | |
| 259 | 259 | * 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, | |
| 261 | 261 | * your pinned apps, Artifacts, People, Workspace, and "You and | |
| 262 | 262 | * help", which opens your account's sheet. | |
| 263 | 263 | */ | |
| ⋯ | |||
| 283 | 283 | </div> | |
| 284 | 284 | <div className="grid grid-cols-4 gap-1.5"> | |
| 285 | 285 | <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" /> | |
| 287 | 287 | {shown.map((app) => ( | |
| 288 | 288 | <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} /> | |
| 289 | 289 | ))} | |
| 55 | 55 | Scale, | |
| 56 | 56 | Smile, | |
| 57 | 57 | Sparkles, | |
| 58 | − | Store, | |
| 58 | + | SlidersHorizontal, | |
| 59 | 59 | House, | |
| 60 | 60 | Ticket, | |
| 61 | 61 | TrendingUp, | |
| ⋯ | |||
| 2104 | 2104 | artifacts: "Artifacts", | |
| 2105 | 2105 | home: "Home", | |
| 2106 | 2106 | apps: "Apps", | |
| 2107 | − | marketplace: "Marketplace", | |
| 2107 | + | customize: "Customize", | |
| 2108 | 2108 | "code-access": "Code access", | |
| 2109 | 2109 | overview: "Overview", | |
| 2110 | 2110 | workspace: "Workspace", | |
| ⋯ | |||
| 2208 | 2208 | label: third === "settings" ? "General" : (SECTIONS[third] ?? third), | |
| 2209 | 2209 | to: page, | |
| 2210 | 2210 | }); | |
| 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. | |
| 2212 | 2212 | const tabs: Record<string, string> = | |
| 2213 | − | third === "marketplace" | |
| 2213 | + | third === "customize" | |
| 2214 | 2214 | ? { | |
| 2215 | − | integrations: "Integrations", | |
| 2216 | − | extensions: "Extensions", | |
| 2215 | + | skills: "Skills", | |
| 2216 | + | connectors: "Connectors", | |
| 2217 | + | plugins: "Plugins", | |
| 2218 | + | marketplaces: "Marketplaces", | |
| 2217 | 2219 | requests: "Requests", | |
| 2218 | 2220 | } | |
| 2219 | 2221 | : third === "agents" | |
| ⋯ | |||
| 2424 | 2426 | icon: <LayoutGrid size={15} />, | |
| 2425 | 2427 | }, | |
| 2426 | 2428 | { | |
| 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} />, | |
| 2431 | 2433 | }, | |
| 2432 | 2434 | { | |
| 2433 | 2435 | label: "Agent templates", | |
| 1 | − | import { ChevronRight } from "lucide-react"; | |
| 1 | + | import { Check, ChevronRight, Circle } from "lucide-react"; | |
| 2 | 2 | import { DropdownMenu as Primitive } from "radix-ui"; | |
| 3 | 3 | import type { ComponentProps } from "react"; | |
| 4 | 4 | ||
| ⋯ | |||
| 121 | 121 | </Primitive.Portal> | |
| 122 | 122 | ); | |
| 123 | 123 | } | |
| 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 | + | } | |
| 36 | 36 | assert.equal(owner?.action, "connect"); | |
| 37 | 37 | assert.equal(owner?.href, "/acme/-/integrations/trackers?add=linear#add"); | |
| 38 | 38 | 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" }); | |
| 40 | 40 | assert.equal(integrationHint("acme", { id: "box", why: "" }, true)?.action, "soon"); | |
| 41 | 41 | assert.equal(integrationHint("acme", { id: "nope", why: "" }, true), null); | |
| 42 | 42 | }); |
| 63 | 63 | why: string; | |
| 64 | 64 | /** `connect` (an owner sets it up), `ask` (anyone else asks an owner), `soon` (nobody can yet). */ | |
| 65 | 65 | 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. */ | |
| 67 | 67 | href: string; | |
| 68 | 68 | }; | |
| 69 | 69 | ||
| 70 | 70 | export function integrationHint(slug: string, suggestion: { id: string; why: string }, owner: boolean): IntegrationHint | null { | |
| 71 | 71 | const connector = connectorById(suggestion.id); | |
| 72 | 72 | 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)}`; | |
| 74 | 74 | const action = connector.status === "soon" ? "soon" : owner ? "connect" : "ask"; | |
| 75 | 75 | return { id: connector.id, name: connector.name, why: suggestion.why, action, href: action === "connect" && connector.href?.workspace ? connectorPath(connector.href.workspace, slug) : href }; | |
| 76 | 76 | } |
| 21 | 21 | writeDock, | |
| 22 | 22 | } from "./apps.ts"; | |
| 23 | 23 | ||
| 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", () => { | |
| 25 | 25 | for (const app of BUILTINS) assert.equal(isPinnable(app.key), false, app.key); | |
| 26 | 26 | for (const page of ["projects", "packages", "security", "context", "memory", "teams", "usage", "gateway", "integrations", "audit"]) { | |
| 27 | 27 | assert.equal(isPinnable(page), false, page); | |
| ⋯ | |||
| 29 | 29 | assert.equal(isPinnable("int-sentry"), true); | |
| 30 | 30 | assert.equal(isPinnable("int-slack"), false, "Slack can't be connected yet"); | |
| 31 | 31 | 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); | |
| 33 | 35 | assert.equal(isPinnable("int-Sentry"), false); | |
| 34 | 36 | }); | |
| 35 | 37 | ||
| ⋯ | |||
| 43 | 45 | assert.ok(integrationAppKey("anthropic-endpoint").length <= 32); | |
| 44 | 46 | }); | |
| 45 | 47 | ||
| 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", () => { | |
| 47 | 49 | const apps = installedApps({ sentry: { manage: "/acme/-/integrations/alerts" }, github: { manage: null }, nonsense: { manage: null } }); | |
| 48 | 50 | assert.deepEqual(apps.map((app) => app.key), ["int-github", "int-sentry"]); | |
| 49 | 51 | assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme"); | |
| 50 | 52 | assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/integrations/alerts"); | |
| 51 | 53 | 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"); | |
| 52 | 69 | }); | |
| 53 | 70 | ||
| 54 | 71 | test("pins are kept per workspace, in the order they were pinned", () => { | |
| ⋯ | |||
| 68 | 85 | assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS); | |
| 69 | 86 | }); | |
| 70 | 87 | ||
| 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", () => { | |
| 72 | 89 | assert.deepEqual(pinsFrom(["usage", "int-sentry", "projects", "today", "int-sentry", "teams", "int-linear"]), ["int-sentry", "int-linear"]); | |
| 73 | 90 | assert.deepEqual(pinsIn("acme:projects.usage.teams", "acme"), []); | |
| 74 | 91 | assert.deepEqual(pinsFrom([]), []); | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…