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.

364 linesCodeBlameRaw
1import { ArrowUpCircle, BookOpen, ChartColumn, Check, Code, FileText, FolderOpen, Globe, Library, type LucideIcon, Plus, Send, Store } from "lucide-react";
2import { Link, data, useFetcher, useOutletContext } from "react-router";
3
4import {
5 type AgentSkill,
6 type AgentSkillLine,
7 type AgentSkills,
8 FOUNDATIONAL_SKILLS,
9 FOUNDATIONAL_SKILLS_VERSION,
10 FOUNDATIONAL_SKILL_IDS,
11 type LibrarySkill,
12 type SkillAbility,
13 type SkillCategory,
14 type WorkspaceAgent,
15} from "@g1t/contracts";
16
17import type { Route } from "./+types/skills";
18import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
19import { type ActionResult } from "../../../components/agents/dialogs";
20import { AttachToAgentDialog, AttachmentChip, NeedsComputer, skillsPath } from "../../../components/agents/skills";
21import { ButtonLink } from "../../../components/ui";
22import { Alert } from "../../../components/ui/alert";
23import { Badge } from "../../../components/ui/badge";
24import { Button } from "../../../components/ui/button";
25import { Card } from "../../../components/ui/card";
26import { Hint } from "../../../components/ui/hint";
27import { Switch } from "../../../components/ui/switch";
28import { cn } from "../../../lib/cn";
29import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
30import { requireUser, roleIn } from "../../../lib/session.server";
31
32/** A library skill's id, as an agent's `skills_off` holds it. */
33const LIBRARY_ID = /^skl_[0-9a-z]{26}$/;
34
35/**
36 * The agent's skills: whether the viewer owns the workspace (owners turn
37 * skills on and off), the library's skills that reach it, and for owners
38 * the library's others, to attach.
39 */
40export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; skills: AgentSkills | null; attachable: Pick<LibrarySkill, "name" | "description" | "version">[] }> {
41 const viewer = requireUser(context, request);
42 const slug = params.owner.toLowerCase();
43 const role = roleIn(viewer, slug);
44 if (!role) throw data(null, { status: 404 });
45 const isOwner = role === "owner";
46 const [skills, library] = await Promise.all([
47 readOrNull(skillLibrary.agentSkills(slug, viewer, params.handle.toLowerCase())),
48 isOwner ? readOrNull(skillLibrary.library(slug, viewer)) : Promise.resolve(null),
49 ]);
50 const has = new Set(skills?.skills.map((s) => s.id) ?? []);
51 const attachable = (library?.skills ?? []).filter((s) => s.status === "published" && !has.has(s.id)).map((s) => ({ name: s.name, description: s.description, version: s.version }));
52 return { isOwner, skills, attachable };
53}
54
55/** Turns a skill on or off (a new version of the agent), attaches one, or moves one to its newest version. */
56export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
57 const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner);
58 const intent = String(form.get("intent") ?? "");
59 const handle = params.handle.toLowerCase();
60 if (intent === "attach") return answer(intent, skillLibrary.attachSkill(slug, viewer, String(form.get("name") ?? ""), "agent", handle));
61 if (intent === "pin") return answer(intent, skillLibrary.pinSkill(slug, viewer, String(form.get("name") ?? ""), String(form.get("attachment") ?? ""), null));
62 if (intent !== "skill") return { ok: false, intent, error: "Unknown request." };
63 if (!isOwner) return { ok: false, intent, error: "Only the workspace's owners turn an agent's skills on or off." };
64 const skill = String(form.get("skill") ?? "");
65 if (!FOUNDATIONAL_SKILL_IDS.includes(skill) && !LIBRARY_ID.test(skill)) return { ok: false, intent, error: "There is no such skill." };
66 const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null);
67 if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
68 if (!current.ok) return { ok: false, intent, error: current.error.message };
69 const off = new Set(current.value.skills_off ?? []);
70 if (form.get("on") === "true") off.delete(skill);
71 else off.add(skill);
72 return answer(intent, workspaceAgents.update(slug, handle, viewer, { skills_off: [...off] }));
73}
74
75const ICONS: Record<SkillCategory, LucideIcon> = { documents: FileText, research: Globe, data: ChartColumn, code: Code, communication: Send, files: FolderOpen };
76
77/**
78 * An agent's skills: g1t's foundational ones, what each does today and
79 * with which of the agent's tools, what's coming; then the library's that
80 * reach it, through it, its teams or every agent. Owners switch each on or
81 * off, attach more, and move one to its newest version.
82 */
83export default function SkillsTab({ loaderData, params }: Route.ComponentProps) {
84 const agent = useOutletContext<WorkspaceAgent>();
85 const { isOwner, skills, attachable } = loaderData;
86 const fetcher = useFetcher<ActionResult>({ key: `skills-${agent.id}` });
87 // While a switch's change is on its way, it shows as changed.
88 const pending = fetcher.formData && fetcher.formData.get("intent") === "skill" ? { skill: String(fetcher.formData.get("skill")), on: fetcher.formData.get("on") === "true" } : null;
89 const isOn = (id: string) => (pending?.skill === id ? pending.on : !(agent.skills_off ?? []).includes(id));
90 const onCount = FOUNDATIONAL_SKILLS.filter((skill) => isOn(skill.id)).length;
91 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
92 const library = skills?.skills.filter((s) => !s.foundational) ?? [];
93 const slug = params.owner;
94 return (
95 <div className="space-y-10">
96 <p className="max-w-2xl text-sm text-muted">
97 Skills tell {agent.display_name} how to do a kind of work with the tools it already has, so asking it for a PDF gets you a PDF. It sees each skill&apos;s name and when to
98 use it, and reads the rest when a request matches. Skills never add a tool or a permission, and say plainly what isn&apos;t possible yet.
99 </p>
100 {error && (
101 <Alert asChild>
102 <p role="alert">
103 {error}
104 </p>
105 </Alert>
106 )}
107
108 <section aria-labelledby="foundational">
109 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
110 <h2 id="foundational" className="text-sm font-medium">
111 Foundational, from g1t <span className="text-faint">{onCount === FOUNDATIONAL_SKILLS.length ? FOUNDATIONAL_SKILLS.length : `${onCount} of ${FOUNDATIONAL_SKILLS.length} on`}</span>
112 </h2>
113 <p className="text-xs text-faint">Version {FOUNDATIONAL_SKILLS_VERSION}, updated with every release</p>
114 </div>
115 {isOwner && <p className="mt-1 text-xs text-faint">Turning a skill off takes it out of {agent.display_name}&apos;s instructions. Its tools stay as they are.</p>}
116 <div className="mt-4 grid gap-3 lg:grid-cols-2">
117 {FOUNDATIONAL_SKILLS.map((skill) => (
118 <SkillCard key={skill.id} skill={skill} on={isOn(skill.id)} agentName={agent.display_name} isOwner={isOwner} fetcher={fetcher} />
119 ))}
120 </div>
121 </section>
122
123 <section aria-labelledby="library">
124 <div className="flex flex-wrap items-center justify-between gap-3">
125 <div className="min-w-0">
126 <h2 id="library" className="text-sm font-medium">
127 From your library <span className="text-faint">{library.length}</span>
128 </h2>
129 <p className="mt-1 text-xs text-faint">Attached to {agent.display_name}, to a team it is on, or to every agent. Each uses the version pinned where it is attached.</p>
130 </div>
131 <div className="flex shrink-0 flex-wrap gap-2">
132 <ButtonLink to={skillsPath(slug)} variant="outline" size="sm">
133 <Library size={13} />
134 Open the library
135 </ButtonLink>
136 {isOwner && (
137 <AttachToAgentDialog
138 agentName={agent.display_name}
139 skills={attachable}
140 trigger={
141 <Button type="button" variant="outline" size="sm">
142 <Plus size={13} />
143 Attach a skill
144 </Button>
145 }
146 />
147 )}
148 </div>
149 </div>
150 {skills == null ? (
151 <Card asChild tone="plain" className="mt-3 border-dashed px-4 py-6 text-center text-sm text-muted">
152 <p>The library didn&apos;t answer. Reload in a moment.</p>
153 </Card>
154 ) : library.length === 0 ? (
155 <Card asChild tone="plain" className="mt-3 border-dashed px-4 py-6 text-center text-sm text-muted">
156 <p>
157 None yet. Skills your workspace writes, imports or saves from sessions reach {agent.display_name} once they are attached.
158 </p>
159 </Card>
160 ) : (
161 <Card asChild className="mt-3 divide-y divide-line/60 overflow-hidden">
162 <ul>
163 {library.map((line) => (
164 <LibraryLine key={line.id} slug={slug} line={line} on={isOn(line.id)} agentName={agent.display_name} isOwner={isOwner} fetcher={fetcher} />
165 ))}
166 </ul>
167 </Card>
168 )}
169 {skills && skills.over_limit > 0 && (
170 <p className="mt-2 text-sm text-warn">
171 {agent.display_name} has {skills.over_limit} more than the 100 library skills an agent can have; it doesn&apos;t get the last {skills.over_limit}. Turn some off or
172 detach them.
173 </p>
174 )}
175 </section>
176
177 <Card asChild className="divide-y divide-line/60 overflow-hidden">
178 <section aria-label="Coming">
179 <ComingRow icon={Globe} title="Web access" body="Searching and reading the open web, set per team: open, approved sites only, or off." />
180 <ComingRow icon={Store} title="Skills from the Marketplace" body="Skills that extensions bring, added in one step." />
181 </section>
182 </Card>
183 </div>
184 );
185}
186
187function ComingRow({ icon: Icon, title, body }: { icon: LucideIcon; title: string; body: string }) {
188 return (
189 <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
190 <div className="flex min-w-0 items-start gap-3">
191 <Icon size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden />
192 <div className="min-w-0">
193 <h3 className="text-sm font-medium">{title}</h3>
194 <p className="mt-0.5 text-sm text-muted">{body}</p>
195 </div>
196 </div>
197 <ComingBadge />
198 </div>
199 );
200}
201
202function ComingBadge() {
203 return <Badge tone="neutral">Coming</Badge>;
204}
205
206function LibraryLine({
207 slug,
208 line,
209 on,
210 agentName,
211 isOwner,
212 fetcher,
213}: {
214 slug: string;
215 line: AgentSkillLine;
216 on: boolean;
217 agentName: string;
218 isOwner: boolean;
219 fetcher: ReturnType<typeof useFetcher<ActionResult>>;
220}) {
221 const toggle = (next: boolean) => fetcher.submit({ intent: "skill", skill: line.id, on: String(next) }, { method: "post" });
222 return (
223 <li className="flex items-start gap-3 px-4 py-3.5">
224 <div className={cn("min-w-0 grow", !on && "opacity-60")}>
225 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
226 <Link to={skillsPath(slug, line.name)} className="font-mono text-sm font-medium text-fg hover:underline">
227 {line.name}
228 </Link>
229 <span className="text-xs text-faint">v{line.version}</span>
230 {line.requires_computer && <NeedsComputer />}
231 </div>
232 <p className="mt-0.5 text-sm text-muted">{line.description}</p>
233 <div className="mt-2 flex flex-wrap items-center gap-2">
234 {line.via && <AttachmentChip attachment={{ scope: line.via, label: line.via_label ?? "", version: Number(line.version) }} />}
235 {line.update != null &&
236 (line.can_change ? (
237 <fetcher.Form method="post">
238 <input type="hidden" name="intent" value="pin" />
239 <input type="hidden" name="name" value={line.name} />
240 <input type="hidden" name="attachment" value={line.attachment_id ?? ""} />
241 <Hint label={line.via === "agent" ? `Move ${agentName} to version ${line.update}` : `Moves it to version ${line.update} for every agent it reaches through ${line.via_label}`}>
242 <Button type="submit" variant="link" size="xs" className="h-6 px-1.5 hover:bg-accent/10" disabled={fetcher.state !== "idle"}>
243 <ArrowUpCircle size={13} />
244 Update to v{line.update}
245 </Button>
246 </Hint>
247 </fetcher.Form>
248 ) : (
249 <span className="text-xs text-warn">Version {line.update} is out</span>
250 ))}
251 </div>
252 </div>
253 {isOwner ? (
254 <Hint label={on ? `Turn off ${line.name}` : `Turn on ${line.name}`}>
255 <span className="mt-0.5 inline-flex">
256 <Switch checked={on} onCheckedChange={toggle} aria-label={`${line.name} for ${agentName}`} disabled={fetcher.state !== "idle"} />
257 </span>
258 </Hint>
259 ) : (
260 <Badge tone={on ? "success" : "neutral"} className="mt-0.5 shrink-0">
261 {on ? "On" : "Off"}
262 </Badge>
263 )}
264 </li>
265 );
266}
267
268function SkillCard({
269 skill,
270 on,
271 agentName,
272 isOwner,
273 fetcher,
274}: {
275 skill: AgentSkill;
276 on: boolean;
277 agentName: string;
278 isOwner: boolean;
279 fetcher: ReturnType<typeof useFetcher<ActionResult>>;
280}) {
281 const Icon = ICONS[skill.category];
282 const ready = skill.abilities.filter((ability) => ability.status === "ready");
283 const coming = skill.abilities.filter((ability) => ability.status === "coming");
284 const toggle = (next: boolean) => fetcher.submit({ intent: "skill", skill: skill.id, on: String(next) }, { method: "post" });
285 const label = `${skill.name} for ${agentName}`;
286 return (
287 <article className={cn("flex flex-col rounded-xl border border-line bg-surface", !on && "bg-transparent")} aria-labelledby={`skill-${skill.id}`}>
288 <header className="flex items-start gap-3 p-4 pb-3">
289 <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", on ? "bg-accent/10 text-accent" : "bg-raised text-faint")}>
290 <Icon size={17} aria-hidden />
291 </span>
292 <div className="min-w-0 grow">
293 <h3 id={`skill-${skill.id}`} className={cn("text-sm font-semibold", on ? "text-fg" : "text-muted")}>
294 {skill.name}
295 </h3>
296 <p className="mt-0.5 text-sm text-muted">{skill.description}</p>
297 </div>
298 {isOwner ? (
299 <Hint label={on ? `Turn off ${skill.name}` : `Turn on ${skill.name}`}>
300 <span className="mt-0.5 inline-flex">
301 <Switch checked={on} onCheckedChange={toggle} aria-label={label} disabled={fetcher.state !== "idle"} />
302 </span>
303 </Hint>
304 ) : (
305 <Badge tone={on ? "success" : "neutral"} className="mt-0.5 shrink-0">
306 {on ? "On" : "Off"}
307 </Badge>
308 )}
309 </header>
310 <ul className={cn("space-y-2.5 border-t border-line/60 px-4 py-3", !on && "opacity-60")}>
311 {ready.map((ability) => (
312 <Ability key={ability.id} ability={ability} />
313 ))}
314 {coming.map((ability) => (
315 <Ability key={ability.id} ability={ability} />
316 ))}
317 </ul>
318 <details className="group mt-auto border-t border-line/60">
319 <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
320 <BookOpen size={13} aria-hidden />
321 <span className="group-open:hidden">Read the playbook</span>
322 <span className="hidden group-open:inline">Hide the playbook</span>
323 </summary>
324 <div className="px-4 pb-4">
325 <p className="text-xs text-faint">
326 What {agentName} reads when it uses {skill.name}. {skill.when}
327 </p>
328 <p className="mt-2 rounded-lg bg-raised/60 px-3 py-2.5 text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{skill.instructions}</p>
329 </div>
330 </details>
331 </article>
332 );
333}
334
335function Ability({ ability }: { ability: SkillAbility }) {
336 const ready = ability.status === "ready";
337 return (
338 <li className="flex items-start gap-2.5">
339 {ready ? (
340 <Check size={14} className="mt-0.75 shrink-0 text-success" aria-label="Works today" />
341 ) : (
342 <span className="mt-0.75 flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
343 <span className="size-2 rounded-full border border-line-strong" />
344 </span>
345 )}
346 <div className="min-w-0 grow">
347 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
348 <span className={cn("text-sm", ready ? "text-fg" : "text-muted")}>{ability.label}</span>
349 {!ready && <ComingBadge />}
350 </div>
351 <p className="mt-0.5 text-xs text-faint">{ability.note}</p>
352 {ready && ability.tools.length > 0 && (
353 <ul className="mt-1.5 flex flex-wrap gap-1" aria-label="Tools it uses">
354 {ability.tools.map((tool) => (
355 <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">
356 {tool}
357 </li>
358 ))}
359 </ul>
360 )}
361 </div>
362 </li>
363 );
364}