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