Skip to content
204 linesCodeBlameRaw
1/**
2 * The skill library's shared parts (docs.g1t.sh/guides/agent-skills/):
3 * where a skill came from, where it is attached, the "needs a computer"
4 * mark, and the dialogs that attach one.
5 */
6import { Bot, Building2, Cpu, GitBranch, Users } from "lucide-react";
7import { type ReactNode, useId, useState } from "react";
8
9import type { LibrarySkill, SkillAttachment, SkillLibrary, SkillOrigin, SkillScope } from "@g1t/contracts";
10
11import { Badge } from "../ui/badge";
12import { Button } from "../ui/button";
13import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
14import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
15import { Hint } from "../ui/hint";
16import { SelectField } from "../ui/select";
17import { useDialogFetcher } from "./dialogs";
18
19/** `/acme/-/agents/skills`, or one skill's page. */
20export function skillsPath(slug: string, name?: string, rest = ""): string {
21 return `/${slug}/-/agents/skills${name ? `/${encodeURIComponent(name)}` : ""}${rest}`;
22}
23
24/** Where a version came from, in a few words. */
25export function originText(origin: SkillOrigin): string {
26 switch (origin.kind) {
27 case "written":
28 return "Written in g1t";
29 case "upload":
30 return `Uploaded from ${origin.filename}`;
31 case "repository":
32 return `From ${origin.repo}${origin.path && origin.path !== "." ? `, ${origin.path}` : ""} at ${origin.commit.slice(0, 7)}`;
33 case "session":
34 return `Drafted by @${origin.agent} from “${origin.title}”`;
35 case "mirror":
36 return `From ${origin.repo} at ${origin.commit.slice(0, 7)}`;
37 }
38}
39
40const SCOPE_ICONS: Record<SkillScope, typeof Bot> = { agent: Bot, team: Users, workspace: Building2 };
41
42/** Where a skill is attached, as a small chip. */
43export function AttachmentChip({ attachment, latest }: { attachment: Pick<SkillAttachment, "scope" | "label" | "version">; latest?: number }) {
44 const Icon = SCOPE_ICONS[attachment.scope];
45 const behind = latest != null && attachment.version < latest;
46 return (
47 <span className="inline-flex max-w-full items-center gap-1.5 rounded-md bg-raised px-2 py-0.5 text-xs text-fg-soft ring-1 ring-line ring-inset">
48 <Icon size={12} className="shrink-0 text-faint" aria-hidden />
49 <span className="truncate">{attachment.label}</span>
50 {behind && <span className="shrink-0 text-faint">v{attachment.version}</span>}
51 </span>
52 );
53}
54
55/** Marked on a skill that needs the agent's own computer, which is coming. `plain` inside a link, where a hint can't be focused. */
56export function NeedsComputer({ plain }: { plain?: boolean }) {
57 if (plain) {
58 return (
59 <Badge tone="warn">
60 <Cpu size={11} aria-hidden />
61 Needs a computer · Coming
62 </Badge>
63 );
64 }
65 return (
66 <Hint label="Its scripts run only on an agent's own computer, which is coming. Until then agents follow its instructions and never run its scripts.">
67 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
68 <Badge tone="warn">
69 <Cpu size={11} aria-hidden />
70 Needs a computer · Coming
71 </Badge>
72 </span>
73 </Hint>
74 );
75}
76
77/** Marked on a skill that follows the linked repository. */
78export function FromRepository({ repo, plain }: { repo: string | null; plain?: boolean }) {
79 if (plain) {
80 return (
81 <Badge tone="info">
82 <GitBranch size={11} aria-hidden />
83 From the repository
84 </Badge>
85 );
86 }
87 return (
88 <Hint label={repo ? `Changed in ${repo}, under .g1t/skills/: a push there publishes a new version.` : "Changed in the linked repository."}>
89 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
90 <Badge tone="info">
91 <GitBranch size={11} aria-hidden />
92 From the repository
93 </Badge>
94 </span>
95 </Hint>
96 );
97}
98
99/** Where a skill may be attached by this viewer: every agent and agents for owners, the teams they maintain. */
100function targets(library: Pick<SkillLibrary, "can_manage" | "teams" | "agents">, attached: SkillAttachment[]) {
101 const taken = new Set(attached.map((a) => `${a.scope}:${a.target ?? ""}`));
102 const options: { value: string; label: string; disabled?: boolean }[] = [];
103 if (library.can_manage) options.push({ value: "workspace:", label: "Every agent in the workspace", disabled: taken.has("workspace:") });
104 for (const team of library.teams) options.push({ value: `team:${team.slug}`, label: `Team: ${team.name}`, disabled: taken.has(`team:${team.slug}`) });
105 if (library.can_manage) for (const agent of library.agents) options.push({ value: `agent:${agent.handle}`, label: `${agent.display_name} (@${agent.handle})`, disabled: taken.has(`agent:${agent.handle}`) });
106 return options;
107}
108
109/** Attach a skill to every agent, a team or one agent: the newest version is pinned. */
110export function AttachDialog({
111 skill,
112 library,
113 action,
114 trigger,
115}: {
116 skill: LibrarySkill;
117 library: Pick<SkillLibrary, "can_manage" | "teams" | "agents">;
118 action?: string;
119 trigger: ReactNode;
120}) {
121 const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`attach-${skill.id}`);
122 const id = useId();
123 const options = targets(library, skill.attachments);
124 const [where, setWhere] = useState(options.find((o) => !o.disabled)?.value ?? "");
125 const [scope, target] = where ? [where.slice(0, where.indexOf(":")), where.slice(where.indexOf(":") + 1)] : ["", ""];
126 return (
127 <Dialog open={open} onOpenChange={setOpen}>
128 <DialogTrigger asChild>{trigger}</DialogTrigger>
129 <DialogContent>
130 <DialogHeader>
131 <DialogTitle>Attach {skill.name}</DialogTitle>
132 <DialogDescription>
133 Agents it reaches see its name and when to use it, and read it when a request matches. Version {skill.version} is pinned there; a newer version reaches them when someone
134 moves the pin.
135 </DialogDescription>
136 </DialogHeader>
137 <fetcher.Form method="post" action={action} className="grid gap-5">
138 <input type="hidden" name="intent" value="attach" />
139 <input type="hidden" name="scope" value={scope} />
140 <input type="hidden" name="target" value={target} />
141 <Field>
142 <FieldLabel htmlFor={`${id}-where`}>Attach to</FieldLabel>
143 {options.length ? (
144 <SelectField id={`${id}-where`} options={options} value={where} onValueChange={setWhere} placeholder="Choose where" />
145 ) : (
146 <p className="text-sm text-muted">There is nowhere you can attach it. Owners attach skills anywhere; team maintainers attach them to their teams.</p>
147 )}
148 <FieldDescription>A team&apos;s skills reach every agent on it. A skill never gives an agent a tool or access it doesn&apos;t have.</FieldDescription>
149 </Field>
150 <FieldError>{error}</FieldError>
151 <DialogFooter>
152 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
153 Cancel
154 </Button>
155 <Button type="submit" variant="accent" disabled={busy || !where}>
156 {busy ? "Attaching…" : "Attach"}
157 </Button>
158 </DialogFooter>
159 </fetcher.Form>
160 </DialogContent>
161 </Dialog>
162 );
163}
164
165/** From an agent's Skills tab: attach one of the library's skills to this agent. */
166export function AttachToAgentDialog({ agentName, skills, trigger }: { agentName: string; skills: Pick<LibrarySkill, "name" | "description" | "version">[]; trigger: ReactNode }) {
167 const { fetcher, open, setOpen, error, busy } = useDialogFetcher("attach-to-agent");
168 const id = useId();
169 const [name, setName] = useState(skills[0]?.name ?? "");
170 const chosen = skills.find((s) => s.name === name);
171 return (
172 <Dialog open={open} onOpenChange={setOpen}>
173 <DialogTrigger asChild>{trigger}</DialogTrigger>
174 <DialogContent>
175 <DialogHeader>
176 <DialogTitle>Attach a skill to {agentName}</DialogTitle>
177 <DialogDescription>From your workspace&apos;s library. Its newest version is pinned; {agentName} reads it when a request matches.</DialogDescription>
178 </DialogHeader>
179 <fetcher.Form method="post" className="grid gap-5">
180 <input type="hidden" name="intent" value="attach" />
181 <input type="hidden" name="name" value={name} />
182 <Field>
183 <FieldLabel htmlFor={`${id}-skill`}>Skill</FieldLabel>
184 {skills.length ? (
185 <SelectField id={`${id}-skill`} options={skills.map((s) => ({ value: s.name, label: s.name }))} value={name} onValueChange={setName} />
186 ) : (
187 <p className="text-sm text-muted">Every published skill in the library already reaches {agentName}.</p>
188 )}
189 {chosen && <FieldDescription>{chosen.description}</FieldDescription>}
190 </Field>
191 <FieldError>{error}</FieldError>
192 <DialogFooter>
193 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
194 Cancel
195 </Button>
196 <Button type="submit" variant="accent" disabled={busy || !name}>
197 {busy ? "Attaching…" : "Attach"}
198 </Button>
199 </DialogFooter>
200 </fetcher.Form>
201 </DialogContent>
202 </Dialog>
203 );
204}