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