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