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