Skip to content
111 linesCodeBlameRaw
1/**
2 * The Marketplace: what a workspace can add, which anyone in it may
3 * browse. Owners add agents and integrations; everyone else asks an owner,
4 * and the agents service keeps the request (lib/marketplace.ts). It is not
5 * in the dock: the Apps page and the launcher lead here, as does ⌘K.
6 *
7 * This layout holds the heading, the tabs and the viewer's requests, which
8 * every tab reads to say what is waiting.
9 */
10import { Blocks, Bot, Inbox, Plug, Sparkles } from "lucide-react";
11import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router";
12
13import type { ExtensionInstall, InstallRequests } from "@g1t/contracts";
14
15import type { Route } from "./+types/layout";
16import { TabStrip } from "../../../components/ui/tab-strip";
17import { marketplacePath, openRequests } from "../../../lib/marketplace";
18import { page } from "../../../lib/meta";
19import { workspaceAgents } from "../../../lib/services.server";
20import { requireUser, roleIn } from "../../../lib/session.server";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Marketplace · ${params.owner} · g1t` });
24}
25
26export type MarketplaceData = {
27 slug: string;
28 name: string;
29 owner: boolean;
30 username: string;
31 /** The viewer's requests (an owner's: everyone's); null when the agents service did not answer. */
32 requests: InstallRequests | null;
33 /** The extensions installed in the workspace; empty when the agents service did not answer. */
34 installs: ExtensionInstall[];
35};
36
37export async function loader({ params, context, request }: Route.LoaderArgs): Promise<MarketplaceData> {
38 const viewer = requireUser(context, request);
39 const slug = params.owner.toLowerCase();
40 const role = roleIn(viewer, slug);
41 if (!role) throw data(null, { status: 404 });
42 const membership = viewer.workspaces?.find((m) => m.slug === slug);
43 const [requests, installs] = await Promise.all([
44 workspaceAgents
45 .installRequests(slug, viewer)
46 .then((result) => (result.ok ? result.value : null))
47 .catch(() => null),
48 workspaceAgents
49 .extensionInstalls(slug, viewer)
50 .then((result) => (result.ok ? result.value : []))
51 .catch(() => []),
52 ]);
53 return { slug, name: membership?.name?.trim() || slug, owner: role === "owner", username: viewer.username, requests, installs };
54}
55
56/** The Marketplace's own data, from any of its pages. */
57export function useMarketplace(): MarketplaceData {
58 return useRouteLoaderData("routes/workspace/marketplace/layout") as MarketplaceData;
59}
60
61export default function MarketplaceLayout({ loaderData }: Route.ComponentProps) {
62 const { slug, name, owner, requests } = loaderData;
63 const waiting = requests ? openRequests(requests.requests).length : 0;
64 const { pathname } = useLocation();
65 const tabs = [
66 { to: marketplacePath(slug), label: "Discover", icon: <Sparkles size={14} />, end: true },
67 { to: marketplacePath(slug, "agents"), label: "Agents", icon: <Bot size={14} /> },
68 { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> },
69 { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> },
70 { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting },
71 ];
72 return (
73 <main className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
74 <div className="flex flex-wrap items-end justify-between gap-4">
75 <div>
76 <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1>
77 <p className="mt-1 max-w-2xl text-sm text-muted">
78 Agents, integrations and extensions {name} can add.{" "}
79 {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."}
80 </p>
81 </div>
82 <Link to={`/${slug}/-/apps`} className="text-sm text-muted hover:text-fg">
83 Your apps
84 </Link>
85 </div>
86 <TabStrip label="Marketplace" className="mt-6 gap-1 border-b border-line">
87 {tabs.map((tab) => (
88 <NavLink
89 key={tab.to}
90 to={tab.to}
91 end={tab.end}
92 prefetch="intent"
93 data-active={tab.end ? pathname === tab.to : pathname.startsWith(tab.to)}
94 className={({ isActive }) =>
95 `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
96 isActive ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
97 }`
98 }
99 >
100 {tab.icon}
101 {tab.label}
102 {tab.count != null && tab.count > 0 && <span className="rounded-full bg-warn/15 px-1.5 py-px text-xs text-warn tabular-nums">{tab.count}</span>}
103 </NavLink>
104 ))}
105 </TabStrip>
106 <div className="mt-8">
107 <Outlet />
108 </div>
109 </main>
110 );
111}