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. | 1 | import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"; |
| 2 | import { useId } from "react"; | |
| 3 | import { Link, useFetcher } from "react-router"; | |
| 4 | ||
| 5 | import type { Ability, AbilityLevel, AbilitySection, AbilitySource, McpServer } from "@g1t/contracts"; | |
| 6 | import { ABILITY_LEVEL_LABELS } from "@g1t/contracts/abilities"; | |
| 7 | ||
| 8 | import { cn } from "../../lib/cn"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 9 | import { ButtonLink, SubmitButton } from "../ui"; |
| 10 | import { Button } from "../ui/button"; | |
| 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. | 11 | import { Badge } from "../ui/badge"; |
| 12 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; | |
| 13 | import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field"; | |
| 14 | import { Hint } from "../ui/hint"; | |
| 15 | import { Input } from "../ui/input"; | |
| 16 | import { SelectField } from "../ui/select"; | |
| 17 | import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; | |
| 18 | import { type ActionResult, useDialogFetcher } from "./dialogs"; | |
| 19 | ||
| 20 | /** The level choices, short, as the segmented control shows them. */ | |
| 21 | const 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. */ | |
| 24 | const KINDS = { read: "Reads", write: "Writes in g1t", send: "Leaves g1t", restricted: "Restricted" } as const; | |
| 25 | ||
| 26 | type Fetcher = ReturnType<typeof useFetcher<ActionResult>>; | |
| 27 | ||
| 28 | /** What a pending change says a row is, before the page reloads. */ | |
| 29 | function 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. */ | |
| 39 | export 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. */ | |
| 73 | export 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. */ | |
| 106 | export 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. */ | |
| 135 | export 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"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 172 | {integration && !source.connected && (isOwner ? <ButtonLink to={source.href ?? askHref} variant="outline">Connect</ButtonLink> : <ButtonLink to={askHref} variant="outline">Ask an owner</ButtonLink>)} |
| 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. | 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"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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`}> |
| 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. | 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"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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}`}> |
| 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. | 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. */ | |
| 216 | export 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> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 222 | <Button type="button" variant="outline" disabled={count >= max}> |
| 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. | 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'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> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 248 | <Button type="button" variant="outline" onClick={() => setOpen(false)}> |
| 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. | 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 | ||
| 261 | export { McpServerCount }; | |
| 262 | ||
| 263 | /** "2 of 10 servers". */ | |
| 264 | function McpServerCount({ servers, max }: { servers: McpServer[]; max: number }) { | |
| 265 | return <span className="text-faint">{servers.length ? `${servers.length} of ${max}` : ""}</span>; | |
| 266 | } |