Skip to content
265 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.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}