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