Skip to content
266 linesCodeBlameRaw
1import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react";
2import { useId } from "react";
3import { Link, useFetcher } from "react-router";
4
5import type { Ability, AbilityLevel, AbilitySection, AbilitySource, McpServer } from "@g1t/contracts";
6import { ABILITY_LEVEL_LABELS } from "@g1t/contracts/abilities";
7
8import { cn } from "../../lib/cn";
9import { ButtonLink, SubmitButton } from "../ui";
10import { Button } from "../ui/button";
11import { Badge } from "../ui/badge";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
13import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
14import { Hint } from "../ui/hint";
15import { Input } from "../ui/input";
16import { SelectField } from "../ui/select";
17import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
18import { type ActionResult, useDialogFetcher } from "./dialogs";
19
20/** The level choices, short, as the segmented control shows them. */
21const SHORT: Record<AbilityLevel, string> = { alone: "Alone", asked: "When asked", ask: "Ask first", never: "Never" };
22
23/** The words a row's kind gets as a tag. */
24const KINDS = { read: "Reads", write: "Writes in g1t", send: "Leaves g1t", restricted: "Restricted" } as const;
25
26type Fetcher = ReturnType<typeof useFetcher<ActionResult>>;
27
28/** What a pending change says a row is, before the page reloads. */
29function pendingOf(fetcher: Fetcher, id: string): { level?: AbilityLevel; credentials?: string } | null {
30 const data = fetcher.formData;
31 if (!data || data.get("ability") !== id) return null;
32 const intent = data.get("intent");
33 if (intent === "level") return { level: String(data.get("level")) as AbilityLevel };
34 if (intent === "credentials") return { credentials: String(data.get("credentials")) };
35 return null;
36}
37
38/** The level control: a segmented control for those who may change it, the level as a badge for everyone else. */
39export function LevelControl({ ability, agentName, canEdit, fetcher }: { ability: Ability; agentName: string; canEdit: boolean; fetcher: Fetcher }) {
40 const pending = pendingOf(fetcher, ability.id);
41 const level = pending?.level ?? ability.level;
42 if (ability.status === "coming") return <Badge tone="neutral">Coming</Badge>;
43 if (!canEdit || !ability.can_change || ability.choices.length <= 1) {
44 return (
45 <Badge tone={level === "never" ? "danger" : level === "alone" ? "success" : level === "ask" ? "warn" : "info"}>
46 {ability.choices.length <= 1 && ability.group === "g1t" ? "Always on" : ABILITY_LEVEL_LABELS[level]}
47 </Badge>
48 );
49 }
50 return (
51 <ToggleGroup
52 type="single"
53 size="sm"
54 variant="outline"
55 value={level}
56 aria-label={`${ability.label} for ${agentName}`}
57 disabled={fetcher.state !== "idle"}
58 onValueChange={(next) => {
59 if (!next || next === level) return;
60 fetcher.submit({ intent: "level", ability: ability.id, level: next }, { method: "post" });
61 }}
62 >
63 {ability.choices.map((choice) => (
64 <ToggleGroupItem key={choice} value={choice} aria-label={ABILITY_LEVEL_LABELS[choice]}>
65 {SHORT[choice]}
66 </ToggleGroupItem>
67 ))}
68 </ToggleGroup>
69 );
70}
71
72/** Whose connection an integration ability runs on: the workspace's, or the asking person's own. */
73export function CredentialsControl({ ability, source, canEdit, fetcher }: { ability: Ability; source: AbilitySource; canEdit: boolean; fetcher: Fetcher }) {
74 if (ability.credentials === null) return null;
75 const pending = pendingOf(fetcher, ability.id);
76 const value = pending?.credentials ?? ability.credentials;
77 const options = [
78 { value: "workspace", label: "Workspace connection", description: `Acts as the workspace's ${source.name} connection. Owners choose this.` },
79 {
80 value: "asker",
81 label: "Asker's connection",
82 description: ability.personal_available ? `Acts as the asking person's own ${source.name} account; a Connect card when they have none.` : `${source.name} can't be connected per person yet.`,
83 disabled: !ability.personal_available,
84 },
85 ];
86 if (!canEdit || !ability.can_change) {
87 return <span className="text-xs text-faint">{value === "asker" ? "Asker's connection" : "Workspace connection"}</span>;
88 }
89 return (
90 <SelectField
91 size="sm"
92 className="w-auto min-w-44"
93 aria-label={`Whose ${source.name} connection ${ability.label.toLowerCase()} runs on`}
94 options={options}
95 value={value}
96 disabled={fetcher.state !== "idle"}
97 onValueChange={(next) => {
98 if (next === value) return;
99 fetcher.submit({ intent: "credentials", ability: ability.id, credentials: next }, { method: "post" });
100 }}
101 />
102 );
103}
104
105/** One ability's row: what it is, its tools, its level, and whose connection. */
106export function AbilityRow({ ability, source, agentName, canEdit, fetcher }: { ability: Ability; source: AbilitySource; agentName: string; canEdit: boolean; fetcher: Fetcher }) {
107 const off = ability.level === "never" || ability.status === "coming" || (ability.group === "integration" && !source.connected);
108 return (
109 <li className="flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-start md:gap-6">
110 <div className={cn("min-w-0 grow", off && "opacity-70")}>
111 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
112 <h4 className="text-sm font-medium">{ability.label}</h4>
113 {ability.status === "ready" && <Badge tone={ability.kind === "restricted" ? "danger" : ability.kind === "send" ? "warn" : "neutral"}>{KINDS[ability.kind]}</Badge>}
114 </div>
115 <p className="mt-0.5 text-sm text-muted">{ability.about}</p>
116 {ability.tools.length > 0 && (
117 <ul className="mt-1.5 flex flex-wrap gap-1" aria-label="Tools it offers">
118 {ability.tools.map((tool) => (
119 <li key={tool} className="rounded-[5px] bg-raised px-1.5 py-px font-mono text-[0.6875rem] text-muted ring-1 ring-line ring-inset">
120 {tool}
121 </li>
122 ))}
123 </ul>
124 )}
125 </div>
126 <div className="flex shrink-0 flex-wrap items-center gap-2 md:flex-col md:items-end">
127 <LevelControl ability={ability} agentName={agentName} canEdit={canEdit} fetcher={fetcher} />
128 <CredentialsControl ability={ability} source={source} canEdit={canEdit} fetcher={fetcher} />
129 </div>
130 </li>
131 );
132}
133
134/** A source's block: its name, whether it is connected, and its rows. */
135export function SourceBlock({
136 section,
137 source,
138 agent,
139 canEdit,
140 isOwner,
141 slug,
142 fetcher,
143}: {
144 section: AbilitySection;
145 source: AbilitySource;
146 agent: { handle: string; display_name: string };
147 canEdit: boolean;
148 isOwner: boolean;
149 slug: string;
150 fetcher: Fetcher;
151}) {
152 const integration = section.group === "integration";
153 const mcp = section.group === "mcp";
154 const askHref = `/${slug}/-/marketplace/integrations/${source.id}`;
155 return (
156 <article className="overflow-hidden rounded-xl border border-line bg-surface" aria-labelledby={`source-${source.id}`}>
157 {(integration || mcp) && (
158 <header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-line/60 px-4 py-3">
159 <div className="flex min-w-0 flex-wrap items-center gap-2">
160 <h3 id={`source-${source.id}`} className="text-sm font-semibold">
161 {source.name}
162 </h3>
163 {integration && <Badge tone={source.connected ? "success" : "neutral"}>{source.connected ? "Connected" : "Not connected"}</Badge>}
164 {mcp && (
165 <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 {source.href}
167 <ExternalLink size={11} aria-hidden />
168 </a>
169 )}
170 </div>
171 <div className="flex shrink-0 flex-wrap items-center gap-2">
172 {integration && !source.connected && (isOwner ? <ButtonLink to={source.href ?? askHref} variant="outline">Connect</ButtonLink> : <ButtonLink to={askHref} variant="outline">Ask an owner</ButtonLink>)}
173 {integration && source.connected && source.href && (
174 <Link to={source.href} className="text-xs text-muted underline-offset-2 hover:underline">
175 Manage
176 </Link>
177 )}
178 {mcp && isOwner && (
179 <>
180 <fetcher.Form method="post">
181 <input type="hidden" name="intent" value="mcp_refresh" />
182 <input type="hidden" name="server" value={source.id} />
183 <Hint label="List its tools again">
184 <SubmitButton variant="outline" icon pending="" className="inline-flex size-8 items-center justify-center rounded-md border border-line text-muted hover:border-line-strong hover:text-fg" fetcher={fetcher} match={{ intent: "mcp_refresh", server: source.id }} aria-label={`List ${source.name}'s tools again`}>
185 <RefreshCw size={14} />
186 </SubmitButton>
187 </Hint>
188 </fetcher.Form>
189 <fetcher.Form method="post" onSubmit={(event) => !confirm(`Remove the ${source.name} server from ${agent.display_name}? Its tools stop being offered at once.`) && event.preventDefault()}>
190 <input type="hidden" name="intent" value="mcp_remove" />
191 <input type="hidden" name="server" value={source.id} />
192 <Hint label="Remove this server">
193 <SubmitButton variant="destructive" icon pending="" className="inline-flex size-8 items-center justify-center rounded-md border border-danger/40 text-danger hover:border-danger hover:bg-danger/10" fetcher={fetcher} match={{ intent: "mcp_remove", server: source.id }} aria-label={`Remove ${source.name}`}>
194 <Trash2 size={14} />
195 </SubmitButton>
196 </Hint>
197 </fetcher.Form>
198 </>
199 )}
200 </div>
201 </header>
202 )}
203 {source.note && <p className="px-4 py-3 text-sm text-muted">{source.note}</p>}
204 {source.abilities.length > 0 && (
205 <ul className="divide-y divide-line/60">
206 {source.abilities.map((ability) => (
207 <AbilityRow key={ability.id} ability={ability} source={source} agentName={agent.display_name} canEdit={canEdit && (!integration || source.connected)} fetcher={fetcher} />
208 ))}
209 </ul>
210 )}
211 </article>
212 );
213}
214
215/** Owners add an MCP server by name and address; its tools are listed from it before it is saved. */
216export function AddMcpServerDialog({ agentName, count, max }: { agentName: string; count: number; max: number }) {
217 const { fetcher, open, setOpen, error, busy } = useDialogFetcher("mcp-add");
218 const id = useId();
219 return (
220 <Dialog open={open} onOpenChange={setOpen}>
221 <DialogTrigger asChild>
222 <Button type="button" variant="outline" disabled={count >= max}>
223 <Plus size={14} />
224 Add a server
225 </Button>
226 </DialogTrigger>
227 <DialogContent>
228 <DialogHeader>
229 <DialogTitle>Add an MCP server</DialogTitle>
230 <DialogDescription>
231 Its tools become abilities of {agentName}: each a write that asks first, unless the server says the tool only reads. The address must be HTTPS on a public host. Servers that need a key aren&apos;t supported yet.
232 </DialogDescription>
233 </DialogHeader>
234 <fetcher.Form method="post" className="grid gap-5">
235 <input type="hidden" name="intent" value="mcp_add" />
236 <Field>
237 <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel>
238 <Input id={`${id}-name`} name="name" placeholder="weather" required autoComplete="off" pattern="[a-z0-9][a-z0-9-]{1,31}" />
239 <FieldDescription>Lowercase letters, digits and hyphens. Tools are offered as name__tool.</FieldDescription>
240 </Field>
241 <Field>
242 <FieldLabel htmlFor={`${id}-url`}>Address</FieldLabel>
243 <Input id={`${id}-url`} name="url" type="url" placeholder="https://mcp.example.com/mcp" required autoComplete="off" />
244 <FieldDescription>Where it speaks MCP over HTTP.</FieldDescription>
245 </Field>
246 <FieldError>{error}</FieldError>
247 <DialogFooter>
248 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
249 Cancel
250 </Button>
251 <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "mcp_add" }} busy={busy} pending="Listing its tools…">
252 Add server
253 </SubmitButton>
254 </DialogFooter>
255 </fetcher.Form>
256 </DialogContent>
257 </Dialog>
258 );
259}
260
261export { McpServerCount };
262
263/** "2 of 10 servers". */
264function McpServerCount({ servers, max }: { servers: McpServer[]; max: number }) {
265 return <span className="text-faint">{servers.length ? `${servers.length} of ${max}` : ""}</span>;
266}