Skip to content
146 linesCodeBlameRaw
1import { Bot, Monitor, Plug, Server } from "lucide-react";
2import { data, useFetcher, useOutletContext } from "react-router";
3
4import type { AbilityLevel, AbilitySection, WorkspaceAgent } from "@g1t/contracts";
5import { MAX_MCP_SERVERS, abilitiesSummary, autonomyOfLevel, findAbility, resolveAbilities, withSetting } from "@g1t/contracts/abilities";
6import { CONNECTORS, connectorPath, connectorView } from "@g1t/contracts/connectors";
7
8import type { Route } from "./+types/abilities";
9import { AddMcpServerDialog, McpServerCount, SourceBlock } from "../../../components/agents/abilities";
10import { agentsAction, answer } from "../../../components/agents/actions.server";
11import type { ActionResult } from "../../../components/agents/dialogs";
12import { Badge } from "../../../components/ui/badge";
13import { connectedStates } from "../../../lib/connected.server";
14import type { ConnectedState } from "../../../lib/connectors";
15import { workspaceAgents } from "../../../lib/services.server";
16import { requireUser, roleIn } from "../../../lib/session.server";
17
18/**
19 * What the Abilities tab needs beside the agent: who the viewer is, what
20 * the workspace has connected (by connector id), and where each is set up
21 * or managed.
22 */
23export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; viewerId: string; connected: string[]; hrefs: Record<string, string | null> }> {
24 const viewer = requireUser(context, request);
25 const slug = params.owner.toLowerCase();
26 const role = roleIn(viewer, slug);
27 if (!role) throw data(null, { status: 404 });
28 const states: Record<string, ConnectedState> = await connectedStates(slug, viewer).catch(() => ({}));
29 const hrefs: Record<string, string | null> = {};
30 for (const connector of CONNECTORS) {
31 const view = connectorView(connector, "workspace");
32 hrefs[connector.id] = states[connector.id]?.manage ?? (view?.href ? connectorPath(view.href, slug) : null);
33 }
34 return { isOwner: role === "owner", viewerId: viewer.id, connected: Object.keys(states), hrefs };
35}
36
37/**
38 * Changes a level or whose connection (a new version of the agent, like any
39 * change), or adds, refreshes or removes an MCP server. The agents service
40 * decides who may: owners a workspace agent, its member a personal one,
41 * and only owners the servers and the workspace's connection.
42 */
43export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
44 const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner);
45 const intent = String(form.get("intent") ?? "");
46 const handle = params.handle.toLowerCase();
47 if (intent === "mcp_add") return answer(intent, workspaceAgents.addMcpServer(slug, handle, viewer, { name: String(form.get("name") ?? ""), url: String(form.get("url") ?? "") }));
48 if (intent === "mcp_remove") return answer(intent, workspaceAgents.removeMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
49 if (intent === "mcp_refresh") return answer(intent, workspaceAgents.refreshMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
50 if (intent !== "level" && intent !== "credentials") return { ok: false, intent, error: "Unknown request." };
51 const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null);
52 if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
53 if (!current.ok) return { ok: false, intent, error: current.error.message };
54 const agent = current.value;
55 const id = String(form.get("ability") ?? "");
56 const sections = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected: CONNECTORS.map((c) => c.id), personal: agent.scope === "personal" });
57 const found = findAbility(sections, id);
58 if (!found) return { ok: false, intent, error: "There is no such ability." };
59 if (intent === "level") {
60 const level = String(form.get("level") ?? "") as AbilityLevel;
61 if (!found.ability.choices.includes(level)) return { ok: false, intent, error: `${found.ability.label} can't be set to that.` };
62 if (found.ability.autonomy) return answer(intent, workspaceAgents.update(slug, handle, viewer, { autonomy: { [found.ability.autonomy.key]: autonomyOfLevel(found.ability.autonomy.key, level) } }));
63 return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { level }).settings } }));
64 }
65 const credentials = String(form.get("credentials") ?? "");
66 if (credentials !== "workspace" && credentials !== "asker") return { ok: false, intent, error: "Whose connection is the workspace's or the asker's." };
67 // The workspace's connection is the owners' to give.
68 if (credentials === "workspace" && !isOwner) return { ok: false, intent, error: "Only the workspace's owners let an agent act as the workspace's connection." };
69 return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { credentials }).settings } }));
70}
71
72const ICONS = { g1t: Bot, computer: Monitor, integration: Plug, mcp: Server } as const;
73
74/**
75 * The agent's abilities, in groups: g1t's own (always on), its computer
76 * (coming), each connected integration's actions with a level and whose
77 * connection, and the MCP servers owners add. A summary in plain words
78 * sits at the top. Owners edit a workspace agent; a member their own
79 * personal one, within what owners allow.
80 */
81export default function AbilitiesTab({ loaderData, params }: Route.ComponentProps) {
82 const agent = useOutletContext<WorkspaceAgent>();
83 const { isOwner, viewerId, connected, hrefs } = loaderData;
84 const fetcher = useFetcher<ActionResult>({ key: `abilities-${agent.id}` });
85 const personal = agent.scope === "personal";
86 const canEdit = personal ? agent.personal_owner_id === viewerId : isOwner;
87 const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected, hrefs, personal });
88 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
89 const slug = params.owner;
90 const servers = agent.abilities?.mcp_servers ?? [];
91 return (
92 <div className="space-y-10">
93 <section aria-labelledby="summary" className="max-w-3xl">
94 <h2 id="summary" className="sr-only">
95 In short
96 </h2>
97 <p className="text-base leading-relaxed text-fg">{abilitiesSummary(sections)}</p>
98 <p className="mt-2 text-sm text-muted">
99 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>,{" "}
100 <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
101 in code, and a refusal names its rule in the transcript and the audit log.
102 {canEdit ? " Each change is saved as a new version." : personal ? " Its member changes these." : " Owners change these."}
103 </p>
104 </section>
105 {error && (
106 <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
107 {error}
108 </p>
109 )}
110 {sections.map((section) => {
111 const Icon = ICONS[section.group];
112 return (
113 <section key={section.group} aria-labelledby={`group-${section.group}`} className="space-y-3">
114 <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
115 <div className="flex min-w-0 items-start gap-2.5">
116 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
117 <Icon size={15} aria-hidden />
118 </span>
119 <div className="min-w-0">
120 <h2 id={`group-${section.group}`} className="flex flex-wrap items-center gap-2 text-sm font-semibold">
121 {section.title}
122 {section.group === "computer" && section.sources.every((source) => source.abilities.every((ability) => ability.status === "coming")) && <Badge tone="neutral">Coming</Badge>}
123 {section.group === "mcp" && <McpServerCount servers={servers} max={MAX_MCP_SERVERS} />}
124 </h2>
125 <p className="text-sm text-muted">{section.about}</p>
126 </div>
127 </div>
128 {section.group === "mcp" && isOwner && <AddMcpServerDialog agentName={agent.display_name} count={servers.length} max={MAX_MCP_SERVERS} />}
129 </div>
130 {section.sources.length === 0 ? (
131 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
132 {section.group === "mcp" ? (isOwner ? `None yet. Add a server, and its tools become ${agent.display_name}'s abilities.` : "None yet. Owners add them.") : "Nothing here yet."}
133 </p>
134 ) : (
135 <div className="space-y-3">
136 {section.sources.map((source) => (
137 <SourceBlock key={source.id} section={section} source={source} agent={agent} canEdit={canEdit} isOwner={isOwner} slug={slug} fetcher={fetcher} />
138 ))}
139 </div>
140 )}
141 </section>
142 );
143 })}
144 </div>
145 );
146}