Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
201 linesCodeBlameRaw
1import { Bot, ChevronRight, Monitor, Plug, Server } from "lucide-react";
2import { data, useFetcher, useOutletContext } from "react-router";
3
4import type { AbilityLevel, AbilitySection, AgentConnector, McpServer, User, WorkspaceAgent, WorkspaceConnector } from "@g1t/contracts";
5import { MAX_MCP_SERVERS, abilitiesSummary, autonomyOfLevel, findAbility, resolveAbilities, withSetting } from "@g1t/contracts/abilities";
6import { CONNECTORS, connectorPath, connectorView } from "@g1t/contracts/connectors";
7import { customizePath } from "@g1t/contracts/customize";
8
9import type { Route } from "./+types/abilities";
10import { AddMcpServerDialog, McpServerCount, SourceBlock } from "../../../components/agents/abilities";
11import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
12import type { ActionResult } from "../../../components/agents/dialogs";
13import { ButtonLink } from "../../../components/ui";
14import { Badge } from "../../../components/ui/badge";
15import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible";
16import { connectedStates } from "../../../lib/connected.server";
17import type { ConnectedState } from "../../../lib/connectors";
18import { workspaceAgents } from "../../../lib/services.server";
19import { requireUser, roleIn } from "../../../lib/session.server";
20
21/**
22 * The workspace's connectors that reach the agent (docs.g1t.sh/guides/customize/,
23 * "Connectors"), and each as an MCP server of the agent's, so its tools are
24 * abilities with levels like any server's: the service does the same for a
25 * turn (abilitiesWithConnectors), with the connector's id, so the levels
26 * set here are the ones it reads.
27 */
28async function reachingConnectors(slug: string, handle: string, viewer: User): Promise<{ connectors: AgentConnector[] | null; servers: McpServer[] }> {
29 const [reach, all] = await Promise.all([readOrNull(workspaceAgents.agentConnectors(slug, handle, viewer)), readOrNull(workspaceAgents.connectors(slug, viewer))]);
30 const byId = new Map((all ?? []).map((connector) => [connector.id, connector]));
31 const servers = (reach ?? [])
32 .filter((connector) => connector.via !== "agent-only")
33 .map((connector) => byId.get(connector.id))
34 .filter((connector): connector is WorkspaceConnector => connector != null)
35 .map((c) => ({ id: c.id, name: c.name, url: c.url, tools: c.tools, added_by: c.added_by, added_at: c.added_at, checked_at: c.checked_at, problem: c.problem }));
36 return { connectors: reach, servers };
37}
38
39/**
40 * What the Abilities tab needs beside the agent: who the viewer is, what
41 * the workspace has connected (by connector id), where each is set up
42 * or managed, and the connectors that reach the agent.
43 */
44export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; viewerId: string; connected: string[]; hrefs: Record<string, string | null>; connectors: AgentConnector[] | null; servers: McpServer[] }> {
45 const viewer = requireUser(context, request);
46 const slug = params.owner.toLowerCase();
47 const role = roleIn(viewer, slug);
48 if (!role) throw data(null, { status: 404 });
49 const [states, reaching] = await Promise.all([connectedStates(slug, viewer).catch(() => ({}) as Record<string, ConnectedState>), reachingConnectors(slug, params.handle.toLowerCase(), viewer)]);
50 const hrefs: Record<string, string | null> = {};
51 for (const connector of CONNECTORS) {
52 const view = connectorView(connector, "workspace");
53 hrefs[connector.id] = states[connector.id]?.manage ?? (view?.href ? connectorPath(view.href, slug) : null);
54 }
55 return { isOwner: role === "owner", viewerId: viewer.id, connected: Object.keys(states), hrefs, ...reaching };
56}
57
58/**
59 * Changes a level or whose connection (a new version of the agent, like any
60 * change), or adds, refreshes or removes a server only this agent has. The
61 * agents service decides who may: owners a workspace agent, its member a
62 * personal one, and only owners the servers and the workspace's connection.
63 */
64export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
65 const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner);
66 const intent = String(form.get("intent") ?? "");
67 const handle = params.handle.toLowerCase();
68 if (intent === "mcp_add") return answer(intent, workspaceAgents.addMcpServer(slug, handle, viewer, { name: String(form.get("name") ?? ""), url: String(form.get("url") ?? "") }));
69 if (intent === "mcp_remove") return answer(intent, workspaceAgents.removeMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
70 if (intent === "mcp_refresh") return answer(intent, workspaceAgents.refreshMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
71 if (intent !== "level" && intent !== "credentials") return { ok: false, intent, error: "Unknown request." };
72 const [current, reaching] = await Promise.all([workspaceAgents.get(slug, handle, viewer).catch(() => null), reachingConnectors(slug, handle, viewer)]);
73 if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
74 if (!current.ok) return { ok: false, intent, error: current.error.message };
75 const agent = current.value;
76 const id = String(form.get("ability") ?? "");
77 const abilities = { ...agent.abilities, mcp_servers: [...(agent.abilities?.mcp_servers ?? []), ...reaching.servers] };
78 const sections = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: agent.autonomy, connected: CONNECTORS.map((c) => c.id), personal: agent.scope === "personal" });
79 const found = findAbility(sections, id);
80 if (!found) return { ok: false, intent, error: "There is no such ability." };
81 if (intent === "level") {
82 const level = String(form.get("level") ?? "") as AbilityLevel;
83 if (!found.ability.choices.includes(level)) return { ok: false, intent, error: `${found.ability.label} can't be set to that.` };
84 if (found.ability.autonomy) return answer(intent, workspaceAgents.update(slug, handle, viewer, { autonomy: { [found.ability.autonomy.key]: autonomyOfLevel(found.ability.autonomy.key, level) } }));
85 return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { level }).settings } }));
86 }
87 const credentials = String(form.get("credentials") ?? "");
88 if (credentials !== "workspace" && credentials !== "asker") return { ok: false, intent, error: "Whose connection is the workspace's or the asker's." };
89 // The workspace's connection is the owners' to give.
90 if (credentials === "workspace" && !isOwner) return { ok: false, intent, error: "Only the workspace's owners let an agent act as the workspace's connection." };
91 return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { credentials }).settings } }));
92}
93
94const ICONS = { g1t: Bot, computer: Monitor, integration: Plug, mcp: Server } as const;
95
96/**
97 * The agent's abilities, in groups: g1t's own (always on), its computer
98 * (coming), each connected integration's actions with a level and whose
99 * connection, and its connectors: the workspace's that reach it (given in
100 * Customize to every agent, a team of its, or it) and the servers only it
101 * has, which owners add here. A summary in plain words sits at the top.
102 * Owners edit a workspace agent; a member their own personal one, within
103 * what owners allow.
104 */
105export default function AbilitiesTab({ loaderData, params }: Route.ComponentProps) {
106 const agent = useOutletContext<WorkspaceAgent>();
107 const { isOwner, viewerId, connected, hrefs, connectors, servers } = loaderData;
108 const fetcher = useFetcher<ActionResult>({ key: `abilities-${agent.id}` });
109 const personal = agent.scope === "personal";
110 const canEdit = personal ? agent.personal_owner_id === viewerId : isOwner;
111 const own = agent.abilities?.mcp_servers ?? [];
112 const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities: { ...agent.abilities, mcp_servers: [...own, ...servers] }, autonomy: agent.autonomy, connected, hrefs, personal });
113 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
114 const slug = params.owner;
115 const reach = new Map((connectors ?? []).map((connector) => [connector.id, connector]));
116 return (
117 <div className="space-y-10">
118 <section aria-labelledby="summary" className="max-w-3xl">
119 <h2 id="summary" className="sr-only">
120 In short
121 </h2>
122 <p className="text-base leading-relaxed text-fg">{abilitiesSummary(sections)}</p>
123 <p className="mt-2 text-sm text-muted">
124 Each ability runs <strong className="font-medium text-fg">alone</strong>, <strong className="font-medium text-fg">alone when the person asked for it</strong>,{" "}
125 <strong className="font-medium text-fg">after asking first</strong> (a card in chat, for the person it acts for or an owner), or <strong className="font-medium text-fg">never</strong>. Every choice is enforced
126 in code, and a refusal names its rule in the transcript and the audit log.
127 {canEdit ? " Each change is saved as a new version." : personal ? " Its member changes these." : " Owners change these."}
128 </p>
129 </section>
130 {error && (
131 <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
132 {error}
133 </p>
134 )}
135 {sections.map((section) => {
136 const Icon = ICONS[section.group];
137 const mcp = section.group === "mcp";
138 return (
139 <section key={section.group} aria-labelledby={`group-${section.group}`} className="space-y-3">
140 <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
141 <div className="flex min-w-0 items-start gap-2.5">
142 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
143 <Icon size={15} aria-hidden />
144 </span>
145 <div className="min-w-0">
146 <h2 id={`group-${section.group}`} className="flex flex-wrap items-center gap-2 text-sm font-semibold">
147 {mcp ? "Connectors" : section.title}
148 {section.group === "computer" && section.sources.every((source) => source.abilities.every((ability) => ability.status === "coming")) && <Badge tone="neutral">Coming</Badge>}
149 </h2>
150 <p className="text-sm text-muted">
151 {mcp
152 ? "MCP servers the workspace connected in Customize and gave to every agent, a team it is on, or it; and servers only it has. Each tool is a row; a tool that doesn't say it only reads counts as a write."
153 : section.about}
154 </p>
155 </div>
156 </div>
157 {mcp && (
158 <ButtonLink to={customizePath("connectors", slug)} variant="outline" size="sm">
159 Customize
160 <ChevronRight size={13} />
161 </ButtonLink>
162 )}
163 </div>
164 {section.sources.length === 0 ? (
165 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
166 {!mcp
167 ? "Nothing here yet."
168 : connectors === null
169 ? "Its connectors can't be read right now."
170 : isOwner
171 ? `None yet. Connect one in Customize and make it available to ${agent.display_name}, a team it is on, or every agent.`
172 : "None reach it yet. Owners connect them in Customize."}
173 </p>
174 ) : (
175 <div className="space-y-3">
176 {section.sources.map((source) => (
177 <SourceBlock key={source.id} section={section} source={source} agent={agent} canEdit={canEdit} isOwner={isOwner} slug={slug} fetcher={fetcher} connector={reach.get(source.id) ?? null} />
178 ))}
179 </div>
180 )}
181 {mcp && (
182 <Collapsible>
183 <CollapsibleTrigger>
184 <ChevronRight size={14} aria-hidden />
185 Only this agent
186 <McpServerCount servers={own} max={MAX_MCP_SERVERS} />
187 </CollapsibleTrigger>
188 <CollapsibleContent className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted">
189 <p className="max-w-xl">
190 A server that only {agent.display_name} has, by address. Connectors for the whole workspace, a team or several agents are added in Customize → Connectors, where they sign in once and are given out.
191 </p>
192 {isOwner && <AddMcpServerDialog agentName={agent.display_name} count={own.length} max={MAX_MCP_SERVERS} />}
193 </CollapsibleContent>
194 </Collapsible>
195 )}
196 </section>
197 );
198 })}
199 </div>
200 );
201}