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