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