Skip to content
379 linesCodeBlameRaw
1import { Check, GripVertical, Pencil, Plus, Trash2, X } from "lucide-react";
2import { useState } from "react";
3
4import type { AgentRouting, ModelTier, SubagentDef } from "@g1t/contracts";
5
6import { Button } from "./ui/button";
7import { Card } from "./ui/card";
8import { Hint } from "./ui/hint";
9import { Input } from "./ui/input";
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
11import { Sheet, SheetContent, SheetDescription, SheetTitle } from "./ui/sheet";
12import { MAX_RESPONSIBILITIES, TIER_LABELS, clampRouting, cleanSubagentName } from "../lib/agent-form";
13
14const FIELD =
15 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
16
17/**
18 * The teams to add a new agent to as it is made: the workspace's teams the
19 * person manages, as chips to press. Membership is the team's, as anyone's
20 * is; with `name`, the chosen teams post as that field, one each.
21 */
22export function TeamPicker({
23 teams,
24 value,
25 onChange,
26 name,
27}: {
28 teams: { slug: string; name: string }[];
29 /** Controlled: the chosen slugs; otherwise the picker keeps its own. */
30 value?: string[];
31 onChange?: (next: string[]) => void;
32 name?: string;
33}) {
34 const [own, setOwn] = useState<string[]>([]);
35 const chosen = value ?? own;
36 const set = onChange ?? setOwn;
37 if (!teams.length) {
38 return <p className="text-[0.8125rem] text-muted">No teams you can add agents to yet. Owners and a team&apos;s maintainers add agents to it.</p>;
39 }
40 return (
41 <div>
42 <div role="group" aria-label="Add to teams" className="flex flex-wrap gap-1.5">
43 {teams.map((team) => {
44 const on = chosen.includes(team.slug);
45 return (
46 <button
47 key={team.slug}
48 type="button"
49 aria-pressed={on}
50 onClick={() => set(on ? chosen.filter((slug) => slug !== team.slug) : [...chosen, team.slug])}
51 className={`inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[0.8125rem] transition-colors sm:h-8 ${
52 on ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
53 }`}
54 >
55 {on ? <Check size={13} className="text-accent" /> : <Plus size={13} />}
56 {team.name}
57 </button>
58 );
59 })}
60 </div>
61 {name && chosen.map((slug) => <input key={slug} type="hidden" name={name} value={slug} />)}
62 </div>
63 );
64}
65
66/**
67 * An agent's role: its title and what it is responsible for, as a list to
68 * add to, edit and reorder. Making one, the teams to add it to as well:
69 * teams are memberships, changed on the team or the agent's profile after.
70 */
71export function RoleFields({
72 title,
73 responsibilities,
74 joinable,
75 errors,
76 locked,
77 personal = false,
78}: {
79 /** A personal agent: on no team. */
80 personal?: boolean;
81 title: string;
82 responsibilities: string[];
83 /** Making an agent: the teams the person may add it to. Null on a profile, where its Teams section does that. */
84 joinable?: { slug: string; name: string }[] | null;
85 errors: Record<string, string>;
86 locked?: boolean;
87}) {
88 const [items, setItems] = useState<string[]>(responsibilities.length > 0 ? responsibilities : [""]);
89 const [dragging, setDragging] = useState<number | null>(null);
90 const move = (from: number, to: number) =>
91 setItems((now) => {
92 const next = [...now];
93 const [item] = next.splice(from, 1);
94 next.splice(to, 0, item!);
95 return next;
96 });
97 return (
98 <>
99 <div className="max-w-sm">
100 <label htmlFor="title" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft">
101 Title
102 {errors.title && <span className="text-xs font-normal text-danger">{errors.title}</span>}
103 </label>
104 <input
105 id="title"
106 name="title"
107 defaultValue={title}
108 readOnly={locked}
109 placeholder="QA Engineer"
110 className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`}
111 autoComplete="off"
112 data-1p-ignore
113 />
114 </div>
115 {joinable && (
116 <div>
117 <p className="mb-1.5 flex items-baseline justify-between gap-3 text-sm font-medium text-fg-soft">
118 Add to teams
119 <span className="text-xs font-normal text-faint">{personal ? "" : "Optional. Like adding a person."}</span>
120 </p>
121 {personal ? (
122 <p className="text-[0.8125rem] text-muted">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p>
123 ) : (
124 <TeamPicker teams={joinable} name="teams" />
125 )}
126 </div>
127 )}
128 <div>
129 <p className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft">
130 Responsibilities
131 <span className="text-xs font-normal text-faint">Broad duties, not tasks. Up to {MAX_RESPONSIBILITIES}.</span>
132 </p>
133 <ul className="space-y-1.5">
134 {items.map((item, index) => (
135 <li
136 key={index}
137 draggable={!locked && items.length > 1}
138 onDragStart={() => setDragging(index)}
139 onDragOver={(event) => dragging != null && event.preventDefault()}
140 onDrop={() => {
141 if (dragging != null && dragging !== index) move(dragging, index);
142 setDragging(null);
143 }}
144 onDragEnd={() => setDragging(null)}
145 className={`group flex items-center gap-1.5 ${dragging === index ? "opacity-50" : ""}`}
146 >
147 <span className="cursor-grab text-faint opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true">
148 <GripVertical size={14} />
149 </span>
150 <input
151 name="responsibility"
152 value={item}
153 onChange={(event) => setItems((now) => now.map((v, i) => (i === index ? event.target.value : v)))}
154 readOnly={locked}
155 aria-label={`Responsibility ${index + 1}`}
156 placeholder={index === 0 ? "Keep the test suite green and fast" : "Another duty"}
157 className={FIELD}
158 autoComplete="off"
159 data-1p-ignore
160 />
161 {!locked && (
162 <Hint label="Remove">
163 <Button
164 type="button"
165 aria-label={`Remove responsibility ${index + 1}`}
166 onClick={() => setItems((now) => (now.length > 1 ? now.filter((_, i) => i !== index) : [""]))}
167 variant="ghost"
168 size="icon-sm"
169 className="text-faint"
170 >
171 <X size={14} />
172 </Button>
173 </Hint>
174 )}
175 </li>
176 ))}
177 </ul>
178 {!locked && items.length < MAX_RESPONSIBILITIES && (
179 <Button
180 type="button"
181 onClick={() => setItems((now) => [...now, ""])}
182 variant="ghost"
183 size="xs"
184 className="mt-2 gap-1.5 text-[0.8125rem] font-normal"
185 >
186 <Plus size={14} />
187 Add a responsibility
188 </Button>
189 )}
190 </div>
191 </>
192 );
193}
194
195const BLANK_SUBAGENT: SubagentDef = { name: "", description: "", instructions: "", routing: { floor: null, ceiling: null }, max_parallel: 2 };
196
197/**
198 * Subagents: help an agent keeps for its own work, as a list, edited in a
199 * drawer. Their routing stays within the agent's own. Posted as JSON.
200 */
201export function SubagentsField({ initial, routing }: { initial: SubagentDef[]; routing: Pick<AgentRouting, "floor" | "ceiling"> }) {
202 const [list, setList] = useState<SubagentDef[]>(initial);
203 const [editing, setEditing] = useState<{ index: number | null; draft: SubagentDef } | null>(null);
204 const [error, setError] = useState<string | null>(null);
205 const save = () => {
206 if (!editing) return;
207 const name = cleanSubagentName(editing.draft.name);
208 if (!name) return setError("Give it a name, like flake-hunter.");
209 if (list.some((s, i) => s.name === name && i !== editing.index)) return setError(`There's already a ${name}.`);
210 const next: SubagentDef = { ...editing.draft, name, routing: clampRouting(editing.draft.routing, routing) };
211 setList((now) => (editing.index == null ? [...now, next] : now.map((s, i) => (i === editing.index ? next : s))));
212 setEditing(null);
213 };
214 const tier = (value: string): ModelTier | null => (value === "none" ? null : (value as ModelTier));
215 return (
216 <div>
217 <input type="hidden" name="subagents" value={JSON.stringify(list)} />
218 <p className="mb-3 inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted">Used inside tasks: coming soon</p>
219 {list.length === 0 ? (
220 <Card asChild tone="plain" radius="lg" className="border-dashed px-4 py-5 text-sm text-muted">
221 <p>None yet. A subagent is help it calls on for one kind of work, such as finding flaky tests.</p>
222 </Card>
223 ) : (
224 <Card asChild tone="plain" radius="lg" divided>
225 <ul>
226 {list.map((sub, index) => (
227 <li key={sub.name} className="flex items-center gap-3 px-3.5 py-2.5">
228 <span className="min-w-0 grow">
229 <span className="block font-mono text-sm text-fg">{sub.name}</span>
230 <span className="block truncate text-xs text-muted">{sub.description || "No description"}</span>
231 </span>
232 <span className="hidden text-xs text-faint sm:inline">
233 {sub.routing.floor ?? "any"} to {sub.routing.ceiling ?? "any"} · {sub.max_parallel} at once
234 </span>
235 <Hint label="Edit">
236 <Button
237 type="button"
238 aria-label={`Edit ${sub.name}`}
239 onClick={() => {
240 setError(null);
241 setEditing({ index, draft: sub });
242 }}
243 variant="ghost"
244 size="icon-sm"
245 className="text-faint"
246 >
247 <Pencil size={14} />
248 </Button>
249 </Hint>
250 <Hint label="Remove">
251 <Button
252 type="button"
253 aria-label={`Remove ${sub.name}`}
254 onClick={() => setList((now) => now.filter((_, i) => i !== index))}
255 variant="ghost"
256 size="icon-sm"
257 className="text-faint hover:text-danger"
258 >
259 <Trash2 size={14} />
260 </Button>
261 </Hint>
262 </li>
263 ))}
264 </ul>
265 </Card>
266 )}
267 <Button
268 type="button"
269 onClick={() => {
270 setError(null);
271 setEditing({ index: null, draft: { ...BLANK_SUBAGENT, routing: clampRouting(BLANK_SUBAGENT.routing, routing) } });
272 }}
273 variant="ghost"
274 size="xs"
275 className="mt-2 gap-1.5 text-[0.8125rem] font-normal"
276 >
277 <Plus size={14} />
278 Add a subagent
279 </Button>
280 <Sheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)}>
281 <SheetContent side="right" className="w-[28rem] max-w-[100vw]">
282 {editing && (
283 <div className="flex h-full flex-col">
284 <div className="border-b border-line px-5 py-4">
285 <SheetTitle>{editing.index == null ? "New subagent" : editing.draft.name}</SheetTitle>
286 <SheetDescription className="mt-1 text-sm text-muted">Its routing stays within its agent&apos;s own limits.</SheetDescription>
287 </div>
288 <div className="min-h-0 grow space-y-5 overflow-y-auto px-5 py-5">
289 <label className="block">
290 <span className="mb-1.5 block text-sm font-medium text-fg-soft">Name</span>
291 <input
292 value={editing.draft.name}
293 onChange={(event) => setEditing({ ...editing, draft: { ...editing.draft, name: event.target.value } })}
294 placeholder="flake-hunter"
295 className={`${FIELD} font-mono`}
296 autoComplete="off"
297 data-1p-ignore
298 />
299 </label>
300 <label className="block">
301 <span className="mb-1.5 block text-sm font-medium text-fg-soft">Description</span>
302 <input
303 value={editing.draft.description}
304 onChange={(event) => setEditing({ ...editing, draft: { ...editing.draft, description: event.target.value } })}
305 placeholder="Finds which tests flake, and why"
306 className={FIELD}
307 autoComplete="off"
308 data-1p-ignore
309 />
310 </label>
311 <label className="block">
312 <span className="mb-1.5 block text-sm font-medium text-fg-soft">Instructions</span>
313 <textarea
314 value={editing.draft.instructions}
315 onChange={(event) => setEditing({ ...editing, draft: { ...editing.draft, instructions: event.target.value } })}
316 rows={6}
317 placeholder="Rerun the failing test 20 times; report the failure rate and the first stack trace."
318 className={`${FIELD} resize-y font-mono text-[0.8125rem]`}
319 />
320 </label>
321 <div className="grid grid-cols-2 gap-4">
322 {(["floor", "ceiling"] as const).map((key) => (
323 <div key={key}>
324 <span className="mb-1.5 block text-sm font-medium text-fg-soft capitalize">{key}</span>
325 <Select
326 value={editing.draft.routing[key] ?? "none"}
327 onValueChange={(value) =>
328 setEditing({ ...editing, draft: { ...editing.draft, routing: clampRouting({ ...editing.draft.routing, [key]: tier(value) }, routing) } })
329 }
330 >
331 <SelectTrigger aria-label={key}>
332 <SelectValue />
333 </SelectTrigger>
334 <SelectContent>
335 <SelectItem value="none">{key === "floor" ? "No floor" : "No ceiling"}</SelectItem>
336 {(["small", "large", "frontier"] as const).map((t) => (
337 <SelectItem key={t} value={t}>
338 {TIER_LABELS[t].split(":")[0]}
339 </SelectItem>
340 ))}
341 </SelectContent>
342 </Select>
343 </div>
344 ))}
345 </div>
346 <label className="block max-w-40">
347 <span className="mb-1.5 block text-sm font-medium text-fg-soft">At once, in one task</span>
348 <Input
349 type="number"
350 min={1}
351 max={8}
352 value={editing.draft.max_parallel}
353 onChange={(event) =>
354 setEditing({ ...editing, draft: { ...editing.draft, max_parallel: Math.max(1, Math.min(8, Number(event.target.value) || 1)) } })
355 }
356 />
357 </label>
358 </div>
359 <div className="flex items-center gap-3 border-t border-line px-5 py-3">
360 {error && <p className="text-sm text-danger">{error}</p>}
361 <Button
362 type="button"
363 onClick={() => setEditing(null)}
364 variant="outline"
365 className="ml-auto text-fg/90"
366 >
367 Cancel
368 </Button>
369 <Button type="button" onClick={save} variant="accent">
370 {editing.index == null ? "Add" : "Done"}
371 </Button>
372 </div>
373 </div>
374 )}
375 </SheetContent>
376 </Sheet>
377 </div>
378 );
379}