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