Skip to content
740 linesCodeBlameRaw
1import { Activity, Brain, ChevronRight, Dices, Network, Plus, Route as RouteIcon, Sparkles } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router";
4
5import type { AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts";
6
7import { type AgentLike, AgentAvatar, PixelCreature } from "./agent-avatar";
8import { StatusDot, statusLabel } from "./chat/marks";
9import { isOrchestrator } from "./orchestrator";
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
11import { Skeleton } from "./ui/skeleton";
12import { AUTONOMY, type AgentDraft, PRESETS, TIER_LABELS, cleanHandle, dollarsField } from "../lib/agent-form";
13import { Hint } from "./ui/hint";
14import { DEPARTMENTS, RoleFields, SubagentsField } from "./agent-role";
15import type { AgentsLayoutData } from "../routes/workspace/agents/layout";
16
17/** The workspace's agents while an Agents page is open; undefined elsewhere. */
18export function useAgentsData(): AgentsLayoutData | undefined {
19 return useRouteLoaderData("routes/workspace/agents/layout") as AgentsLayoutData | undefined;
20}
21
22/** An agent's face: its picture, or the sparkle mark. */
23export function AgentFace({ agent, size = 20 }: { agent: AgentLike; size?: number }) {
24 return <AgentAvatar agent={agent} size={size} />;
25}
26
27function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
28 return (
29 <NavLink
30 to={to}
31 end={end}
32 prefetch="intent"
33 className={({ isActive }) =>
34 `group flex h-9 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
35 isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
36 }`
37 }
38 >
39 <span className="flex w-5 shrink-0 justify-center">{icon}</span>
40 <span className="min-w-0 grow truncate">{children}</span>
41 {trailing}
42 </NavLink>
43 );
44}
45
46/** An agent as the sidebar lists it: from the Agents pages' own data, or the shell's on any other page. */
47type Listed = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar" | "role" | "status" | "title" | "team" | "department"> & {
48 builtin?: boolean;
49 avatar_seed?: string | null;
50};
51
52/** Where an agent sits in the org chart: its team, else its department. */
53export function placeOf(agent: Pick<WorkspaceAgent, "team" | "department">): string {
54 if (agent.team) return agent.team.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
55 return agent.department?.trim() || "Unplaced";
56}
57
58/** "Margo · QA Engineer": how an agent reads wherever it shows. */
59export function nameAndTitle(agent: Pick<WorkspaceAgent, "display_name" | "title">): string {
60 return agent.title ? `${agent.display_name} · ${agent.title}` : agent.display_name;
61}
62
63/**
64 * Agents mode's sidebar: the fleet's runs, context and memory; g1t, the
65 * orchestrator, pinned at the top; then the specialists, each with its
66 * role and status; and the way to make a new one.
67 */
68export function AgentsSidebar({
69 slug,
70 shellAgents,
71 code,
72 owner,
73 phone = false,
74}: {
75 slug: string;
76 /** A phone's Agents tab: the list as the page, with no heading of its own and no fleet row (the fleet is under it). */
77 phone?: boolean;
78 /** The shell's list, for pages outside `-/agents` (context, memory). */
79 shellAgents: Listed[] | null;
80 /** Context and memory are about the code: only with Code access. */
81 code: boolean;
82 owner: boolean;
83}) {
84 const data = useAgentsData();
85 const agents: Listed[] | null = data?.agents ?? shellAgents;
86 const live = data?.live ?? {};
87 const orchestrator = agents?.find((agent) => isOrchestrator(agent)) ?? null;
88 const specialists = (agents ?? []).filter((agent) => !isOrchestrator(agent));
89 const row = (agent: Listed) => (
90 <li key={agent.id}>
91 <NavLink
92 to={`/${slug}/-/agents/${agent.handle}`}
93 prefetch="intent"
94 className={({ isActive }) =>
95 `group flex h-11 items-center gap-2.5 rounded-md px-2 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
96 }
97 >
98 <span className="relative shrink-0">
99 <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} />
100 {!isOrchestrator(agent) && (
101 <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]" />
102 )}
103 </span>
104 <span className="min-w-0 grow leading-tight">
105 <span className="block truncate text-[0.8125rem] font-medium text-fg">{agent.display_name}</span>
106 <span className="block truncate text-[0.6875rem] text-faint">
107 {isOrchestrator(agent) ? "Orchestrator" : agent.status === "idle" ? agent.title || agent.role : `${statusLabel(agent.status)} · ${agent.title || agent.role}`}
108 </span>
109 </span>
110 {(live[agent.id] ?? 0) > 0 && (
111 <Hint label={`${live[agent.id]} live ${live[agent.id] === 1 ? "session" : "sessions"}`}>
112 <span className="flex h-5 min-w-5 shrink-0 items-center justify-center gap-1 rounded-full bg-accent/15 px-1.5 text-[0.6875rem] font-medium text-accent tabular-nums">
113 <span className="size-1.5 animate-pulse rounded-full bg-accent motion-reduce:animate-none" aria-hidden="true" />
114 {live[agent.id]}
115 <span className="sr-only"> live sessions</span>
116 </span>
117 </Hint>
118 )}
119 </NavLink>
120 </li>
121 );
122 return (
123 <div className="flex h-full flex-col">
124 <div className={`flex h-14 shrink-0 items-center justify-between border-b border-line pr-2.5 pl-4 ${phone ? "hidden" : ""}`}>
125 <h2 className="text-[0.9375rem] font-semibold">Agents</h2>
126 <NavLink
127 to={`/${slug}/-/agents/new`}
128 aria-label="New agent"
129 className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
130 >
131 <Plus size={16} />
132 </NavLink>
133 </div>
134 <nav aria-label="Agents" className={`min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin] ${phone ? "[&_a]:min-h-11" : ""}`}>
135 <div className="space-y-px">
136 {!phone && (
137 <SideLink to={`/${slug}/-/agents`} end icon={<Activity size={15} className="text-faint" />}>
138 Overview
139 </SideLink>
140 )}
141 {code && (
142 <>
143 <SideLink to={`/${slug}/-/context`} icon={<Network size={15} className="text-faint" />}>
144 Context
145 </SideLink>
146 <SideLink to={`/${slug}/-/memory`} icon={<Brain size={15} className="text-faint" />}>
147 Memory
148 </SideLink>
149 </>
150 )}
151 </div>
152 {agents == null ? (
153 data === undefined && shellAgents === null ? (
154 <p className="mt-4 px-2 text-xs leading-relaxed text-faint">The agents service didn&apos;t answer. Your agents will show here once it does.</p>
155 ) : (
156 <div className="mt-5 space-y-3 px-2" aria-busy="true">
157 {[0, 1, 2].map((i) => (
158 <div key={i} className="flex items-center gap-2.5">
159 <Skeleton className="size-6 rounded-md" />
160 <Skeleton className="h-3 w-28" />
161 </div>
162 ))}
163 </div>
164 )
165 ) : (
166 <>
167 {orchestrator && <Group title="Orchestrator">{row(orchestrator)}</Group>}
168 <p className="mt-4 px-2 text-[0.6875rem] leading-snug text-faint">Specialists are colleagues hired into a role. g1t hands them work.</p>
169 {[...orgChart(specialists)].map(([place, members]) => (
170 <Group key={place} title={`${place} · ${members.length}`} collapsible>
171 {members.map(row)}
172 </Group>
173 ))}
174 {specialists.length === 0 && <p className="px-2 py-1 text-xs text-faint">No specialists yet. Hire one into a role.</p>}
175 </>
176 )}
177 {owner && (
178 <NavLink
179 to={`/${slug}/-/agents/new`}
180 className={({ isActive }) =>
181 `mt-2 flex h-9 items-center gap-2.5 rounded-md border border-dashed px-2 text-[0.8125rem] transition-colors ${
182 isActive ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
183 }`
184 }
185 >
186 <span className="flex w-5 justify-center">
187 <Plus size={15} />
188 </span>
189 New agent
190 </NavLink>
191 )}
192 </nav>
193 </div>
194 );
195}
196
197/** Specialists by team or department, each group by name, the groups in the gallery's order. */
198function orgChart(agents: Listed[]): Map<string, Listed[]> {
199 const groups = new Map<string, Listed[]>();
200 for (const agent of agents) groups.set(placeOf(agent), [...(groups.get(placeOf(agent)) ?? []), agent]);
201 const rank = (place: string) => {
202 const at = DEPARTMENTS.indexOf(place);
203 return at < 0 ? DEPARTMENTS.length : at;
204 };
205 return new Map(
206 [...groups]
207 .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
208 .map(([place, list]) => [place, list.sort((x, y) => x.display_name.localeCompare(y.display_name))]),
209 );
210}
211
212function Group({ title, children, collapsible }: { title: string; children: ReactNode; collapsible?: boolean }) {
213 const [open, setOpen] = useState(true);
214 if (collapsible) {
215 return (
216 <section className="mt-3">
217 <button
218 type="button"
219 onClick={() => setOpen(!open)}
220 aria-expanded={open}
221 className="mb-1 flex w-full items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
222 >
223 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
224 {title}
225 </button>
226 {open && <ul className="space-y-px border-l border-line pl-1.5 ml-2.5">{children}</ul>}
227 </section>
228 );
229 }
230 return (
231 <section className="mt-4">
232 <h3 className="mb-1 px-2 text-xs font-medium text-faint">{title}</h3>
233 <ul className="space-y-px">{children}</ul>
234 </section>
235 );
236}
237
238/** The templates g1t ships, and a blank one, to start a new agent from. */
239export function TemplateGallery({ templates, chosen, onChoose }: { templates: AgentTemplate[]; chosen: string | null; onChoose: (id: string | null) => void }) {
240 // By department, in the order the departments are listed; any other after them.
241 const order = (department: string) => {
242 const at = DEPARTMENTS.indexOf(department);
243 return at < 0 ? DEPARTMENTS.length : at;
244 };
245 const groups = new Map<string, AgentTemplate[]>();
246 for (const template of [...templates].sort((a, b) => order(a.department) - order(b.department))) {
247 const key = template.department || "Other";
248 groups.set(key, [...(groups.get(key) ?? []), template]);
249 }
250 return (
251 <div className="space-y-8">
252 {[...groups].map(([department, list]) => (
253 <section key={department}>
254 <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2>
255 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
256 {list.map((template) => (
257 <TemplateCard
258 key={template.id}
259 selected={chosen === template.id}
260 onClick={() => onChoose(template.id)}
261 name={template.display_name}
262 title={template.title}
263 seed={template.handle}
264 duties={template.responsibilities.slice(0, 3)}
265 />
266 ))}
267 </div>
268 </section>
269 ))}
270 <section>
271 <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Your own</h2>
272 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
273 <TemplateCard
274 selected={chosen === "blank"}
275 onClick={() => onChoose("blank")}
276 name="A new role"
277 title="Start from nothing"
278 blank
279 duties={["Give it a name, a title and a team", "Say what it is responsible for"]}
280 />
281 </div>
282 </section>
283 </div>
284 );
285}
286
287/** A role to hire into: its face, a name it suggests, its title and what it does. */
288function TemplateCard({
289 selected,
290 onClick,
291 name,
292 title,
293 seed,
294 duties,
295 blank,
296}: {
297 selected: boolean;
298 onClick: () => void;
299 name: string;
300 title: string;
301 seed?: string;
302 duties: string[];
303 blank?: boolean;
304}) {
305 return (
306 <button
307 type="button"
308 onClick={onClick}
309 aria-pressed={selected}
310 className={`group flex flex-col items-start rounded-xl border p-4 text-left transition-all ${
311 selected
312 ? "border-accent/60 bg-accent/[0.07] ring-1 ring-accent/40"
313 : "border-line bg-surface hover:-translate-y-px hover:border-line-strong hover:bg-raised/40"
314 }`}
315 >
316 <span className="flex w-full items-center gap-3">
317 {blank ? (
318 <span className="flex size-10 items-center justify-center rounded-[11px] border border-dashed border-line-strong text-muted">
319 <Plus size={16} />
320 </span>
321 ) : (
322 <PixelCreature seed={seed ?? name} size={40} />
323 )}
324 <span className="min-w-0 grow">
325 <span className="block truncate text-sm font-semibold">{name}</span>
326 <span className="block truncate text-xs text-muted">{title}</span>
327 </span>
328 <ChevronRight size={15} className={`shrink-0 transition-colors ${selected ? "text-accent" : "text-faint group-hover:text-muted"}`} />
329 </span>
330 <ul className="mt-3 space-y-1">
331 {duties.map((duty) => (
332 <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted">
333 <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" />
334 <span className="line-clamp-1">{duty}</span>
335 </li>
336 ))}
337 </ul>
338 </button>
339 );
340}
341
342const FIELD = "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";
343
344function Label({ children, hint, error, htmlFor }: { children: ReactNode; hint?: ReactNode; error?: string; htmlFor?: string }) {
345 return (
346 <div className="mb-1.5 flex items-baseline justify-between gap-3">
347 <label htmlFor={htmlFor} className="text-sm font-medium text-fg-soft">
348 {children}
349 </label>
350 {error ? <span className="text-xs text-danger">{error}</span> : hint ? <span className="text-xs text-faint">{hint}</span> : null}
351 </div>
352 );
353}
354
355/** A section of the form: what it is on the left, its fields on the right, on a wide screen. */
356function FormSection({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
357 return (
358 <section className="grid gap-x-10 gap-y-4 border-t border-line py-8 first-of-type:border-t-0 first-of-type:pt-0 md:grid-cols-[13rem_1fr]">
359 <div>
360 <h2 className="text-sm font-semibold">{title}</h2>
361 <p className="mt-1 text-[0.8125rem] leading-relaxed text-muted">{about}</p>
362 </div>
363 <div className="min-w-0 space-y-5">{children}</div>
364 </section>
365 );
366}
367
368function TierSelect({ name, value, none }: { name: string; value: ModelTier | null; none: string }) {
369 return (
370 <Select name={name} defaultValue={value ?? "none"}>
371 <SelectTrigger id={name}>
372 <SelectValue />
373 </SelectTrigger>
374 <SelectContent>
375 <SelectItem value="none">{none}</SelectItem>
376 {(["small", "large", "frontier"] as const).map((tier) => (
377 <SelectItem key={tier} value={tier} description={TIER_LABELS[tier].split(": ")[1]}>
378 {TIER_LABELS[tier].split(":")[0]}
379 </SelectItem>
380 ))}
381 </SelectContent>
382 </Select>
383 );
384}
385
386function Money({ name, value, label, hint, error }: { name: string; value: number | null; label: string; hint: string; error?: string }) {
387 return (
388 <div>
389 <Label htmlFor={name} hint={hint} error={error}>
390 {label}
391 </Label>
392 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
393 <span className="pl-3 text-sm text-faint">$</span>
394 <input
395 id={name}
396 name={name}
397 inputMode="decimal"
398 defaultValue={dollarsField(value)}
399 placeholder="No cap"
400 autoComplete="off"
401 data-1p-ignore
402 className="min-w-0 grow bg-transparent px-2 py-2 text-sm tabular-nums outline-none placeholder:text-faint"
403 />
404 </div>
405 </div>
406 );
407}
408
409/** Names to shuffle through when a template has none of its own. */
410const FALLBACK_NAMES = ["Margo", "Juniper", "Otto", "Wren", "Basil", "Nova", "Clementine", "Fig", "Rook", "Pip"];
411
412/**
413 * Everything that defines an agent, for making one and for its Profile:
414 * who it is, its job, its voice, the limits on routing, its budget, what it
415 * may do alone, and how much it takes on at once.
416 */
417export function AgentForm({
418 draft,
419 errors,
420 submit,
421 intent,
422 formKey,
423 nameIdeas = [],
424 locked = false,
425 seed,
426 teams = [],
427}: {
428 /** The workspace's teams, to put it on one. */
429 teams?: { slug: string; name: string }[];
430 draft: AgentDraft;
431 errors?: Record<string, string>;
432 submit: string;
433 intent: string;
434 formKey?: string;
435 /** Names to shuffle through (a template's `name_ideas`); the dice offers them. */
436 nameIdeas?: string[];
437 /** g1t, the orchestrator: its identity and job are fixed; the rest is editable. */
438 locked?: boolean;
439 /** The agent's own `avatar_seed`, once it has one; a new agent's face follows its handle. */
440 seed?: string | null;
441}) {
442 const navigation = useNavigation();
443 // The handle follows the name until someone edits it.
444 const [name, setName] = useState(draft.display_name);
445 const [handle, setHandle] = useState(draft.handle);
446 const [handleEdited, setHandleEdited] = useState(Boolean(draft.handle) && cleanHandle(draft.display_name) !== draft.handle);
447 const ideas = nameIdeas.length > 0 ? nameIdeas : FALLBACK_NAMES;
448 const [idea, setIdea] = useState(() => Math.max(0, ideas.indexOf(draft.display_name)));
449 const rename = (next: string) => {
450 setName(next);
451 if (!handleEdited) setHandle(cleanHandle(next));
452 };
453 const shuffle = () => {
454 const at = (idea + 1) % ideas.length;
455 setIdea(at);
456 rename(ideas[at]!);
457 };
458 const { pathname } = useLocation();
459 const busy = navigation.state === "submitting" && navigation.formAction === pathname;
460 const [preset, setPreset] = useState(draft.personality_preset);
461 const [advanced, setAdvanced] = useState(Boolean(draft.routing.pinned));
462 const e = errors ?? {};
463 const presetAbout = PRESETS.find((p) => p.value === preset)?.about;
464 return (
465 <Form method="post" key={formKey} className="pb-24">
466 <input type="hidden" name="intent" value={intent} />
467 <input type="hidden" name="template" value={draft.template ?? ""} />
468 <FormSection
469 title="Identity"
470 about={
471 locked
472 ? "g1t is the workspace's orchestrator: its name, handle and role are the same everywhere, and it can't be archived."
473 : "How it shows in chat, mentions, assignees and the audit log. Give it a name people will enjoy saying."
474 }
475 >
476 <div className="flex items-center gap-3">
477 <AgentAvatar agent={{ handle: handle || "agent", avatar_seed: seed ?? handle, builtin: locked }} size={44} />
478 <div className="min-w-0 text-sm">
479 <p className="truncate font-semibold">{name || "Your new agent"}</p>
480 <p className="truncate text-xs text-muted">{handle ? `@${handle}` : "@handle"}</p>
481 </div>
482 </div>
483 <div className="grid gap-5 sm:grid-cols-2">
484 <div>
485 <Label htmlFor="display_name" error={e.display_name}>
486 Name
487 </Label>
488 <div className="flex gap-2">
489 <input
490 id="display_name"
491 name="display_name"
492 value={name}
493 onChange={(event) => rename(event.target.value)}
494 readOnly={locked}
495 placeholder="Margo"
496 className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`}
497 autoComplete="off"
498 data-1p-ignore
499 />
500 {!locked && (
501 <Hint label="Another name">
502 <button
503 type="button"
504 aria-label="Another name"
505 onClick={shuffle}
506 className="flex size-[38px] shrink-0 items-center justify-center rounded-md border border-line text-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-fg active:rotate-12"
507 >
508 <Dices size={17} />
509 </button>
510 </Hint>
511 )}
512 </div>
513 </div>
514 <div>
515 <Label htmlFor="handle" error={e.handle} hint="Mentioned as @handle">
516 Handle
517 </Label>
518 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
519 <span className="pl-3 font-mono text-sm text-faint">@</span>
520 <input
521 id="handle"
522 name="handle"
523 value={handle}
524 onChange={(event) => {
525 setHandle(event.target.value);
526 setHandleEdited(true);
527 }}
528 readOnly={locked}
529 placeholder="margo"
530 autoComplete="off"
531 data-1p-ignore
532 className="min-w-0 grow bg-transparent py-2 pr-3 pl-0.5 font-mono text-sm outline-none placeholder:text-faint"
533 />
534 </div>
535 </div>
536 </div>
537 </FormSection>
538
539 {!locked && (
540 <FormSection
541 title="Role"
542 about="Hired into a role, not a task: a title, a team, and what it is responsible for. Shown as its name and title everywhere."
543 >
544 <RoleFields
545 title={draft.title}
546 team={draft.team}
547 department={draft.department}
548 responsibilities={draft.responsibilities}
549 teams={teams}
550 errors={e}
551 locked={locked}
552 />
553 </FormSection>
554 )}
555
556 <FormSection
557 title="Job"
558 about={
559 locked
560 ? "Its job is fixed: know the team, hand work to the right specialist, do it itself when nobody fits, and report. Add your own instructions on top."
561 : "What it is responsible for, how it works, and what good looks like. It reads this before every reply and task."
562 }
563 >
564 <div>
565 <Label htmlFor="instructions" error={e.instructions}>
566 {locked ? "Extra instructions" : "Instructions"}
567 </Label>
568 <textarea
569 id="instructions"
570 name="instructions"
571 defaultValue={draft.instructions}
572 rows={9}
573 placeholder={"Cut a release of g1t every Tuesday.\n- Read the merged pull requests since the last tag.\n- Write the release notes.\n- Ask in #releases before tagging."}
574 className={`${FIELD} resize-y font-mono text-[0.8125rem] leading-relaxed`}
575 />
576 </div>
577 </FormSection>
578
579 {!locked && (
580 <FormSection title="Subagents" about="Help it calls on for one kind of work inside its own tasks. Never members of the workspace, and never wider than the agent itself.">
581 <SubagentsField initial={draft.subagents} routing={draft.routing} />
582 {e.subagents && <p className="text-sm text-danger">{e.subagents}</p>}
583 </FormSection>
584 )}
585
586 <FormSection title="Personality" about="Its voice only: tone, length, how it asks. A personality never changes what it may do.">
587 <div>
588 <input type="hidden" name="personality_preset" value={preset} />
589 <div role="radiogroup" aria-label="Voice" className="grid grid-cols-2 gap-1 rounded-lg bg-surface p-1 ring-1 ring-line sm:grid-cols-4">
590 {PRESETS.map((option) => (
591 <button
592 key={option.value}
593 type="button"
594 role="radio"
595 aria-checked={preset === option.value}
596 onClick={() => setPreset(option.value)}
597 className={`h-8 rounded-md text-[0.8125rem] font-medium transition-colors ${
598 preset === option.value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
599 }`}
600 >
601 {option.label}
602 </button>
603 ))}
604 </div>
605 <p className="mt-2 text-xs text-muted">{presetAbout}</p>
606 </div>
607 <div>
608 <Label htmlFor="personality" hint="Optional">
609 In its own words
610 </Label>
611 <textarea
612 id="personality"
613 name="personality"
614 defaultValue={draft.personality}
615 rows={3}
616 placeholder="Dry humor is fine. No emoji. Asks one question at a time."
617 className={`${FIELD} resize-y`}
618 />
619 </div>
620 </FormSection>
621
622 <FormSection
623 title="Models"
624 about="Nobody picks a model to get work done. g1t routes each step to the tier it needs and records which ran. These only limit the routing."
625 >
626 <div className="flex items-start gap-3 rounded-xl border border-accent/25 bg-accent/[0.06] p-4">
627 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent">
628 <RouteIcon size={16} />
629 </span>
630 <div>
631 <p className="text-sm font-semibold">Auto: routed per step by need</p>
632 <p className="mt-0.5 text-[0.8125rem] leading-relaxed text-muted">
633 Replies and triage run on Fast; making changes on Standard; planning, hard reviews and retries after a failure on Most capable. It steps up after a failure and back down when the cheaper tier works.
634 </p>
635 </div>
636 </div>
637 <div className="grid gap-5 sm:grid-cols-3">
638 <div>
639 <Label htmlFor="floor">Floor</Label>
640 <TierSelect name="floor" value={draft.routing.floor} none="No floor" />
641 </div>
642 <div>
643 <Label htmlFor="ceiling" error={e.ceiling}>
644 Ceiling
645 </Label>
646 <TierSelect name="ceiling" value={draft.routing.ceiling} none="No ceiling" />
647 </div>
648 <div>
649 <Label htmlFor="providers">Providers</Label>
650 <Select name="providers" defaultValue={draft.routing.providers[0] ?? "any"}>
651 <SelectTrigger id="providers">
652 <SelectValue />
653 </SelectTrigger>
654 <SelectContent>
655 <SelectItem value="any" description="g1t's hosted models and the workspace's own">
656 Any the workspace allows
657 </SelectItem>
658 <SelectItem value="g1t" description="Never the workspace's own keys">
659 g1t's hosted models
660 </SelectItem>
661 <SelectItem value="workspace" description="Set up under Integrations; billed by the provider">
662 The workspace's own providers
663 </SelectItem>
664 </SelectContent>
665 </Select>
666 </div>
667 </div>
668 <div className="rounded-lg border border-line">
669 <button
670 type="button"
671 onClick={() => setAdvanced(!advanced)}
672 aria-expanded={advanced}
673 className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[0.8125rem] font-medium text-muted hover:text-fg"
674 >
675 <ChevronRight size={14} className={`transition-transform ${advanced ? "rotate-90" : ""}`} />
676 Advanced
677 <span className="font-normal text-faint">Pin one model</span>
678 </button>
679 <div hidden={!advanced} className="border-t border-line px-3.5 py-3.5">
680 <Label htmlFor="pinned" error={e.pinned} hint="For your own endpoints. Turns routing off.">
681 Pinned model
682 </Label>
683 <input id="pinned" name="pinned" defaultValue={draft.routing.pinned ?? ""} placeholder="provider/model" className={`${FIELD} font-mono`} autoComplete="off" data-1p-ignore />
684 </div>
685 </div>
686 </FormSection>
687
688 <FormSection title="Budget" about="Checked before work starts and enforced while it runs. At 80% of the month it tells you; at 100% it takes no new tasks.">
689 <div className="grid gap-5 sm:grid-cols-3">
690 <Money name="monthly" label="Monthly" hint="Per calendar month" value={draft.budget.monthly_micros} error={e.monthly} />
691 <Money name="daily" label="Daily" hint="Optional" value={draft.budget.daily_micros} error={e.daily} />
692 <Money name="task" label="Per session" hint="Going over asks an owner" value={draft.budget.task_micros} error={e.task} />
693 </div>
694 </FormSection>
695
696 <FormSection title="Autonomy" about="What it may do alone and what needs a person. Rulesets and branch protection still apply on top.">
697 <div className="grid gap-5 sm:grid-cols-2">
698 {(Object.keys(AUTONOMY) as (keyof typeof AUTONOMY)[]).map((key) => (
699 <div key={key}>
700 <Label htmlFor={key}>{AUTONOMY[key].label}</Label>
701 <Select name={key} defaultValue={draft.autonomy[key]}>
702 <SelectTrigger id={key}>
703 <SelectValue />
704 </SelectTrigger>
705 <SelectContent>
706 {AUTONOMY[key].options.map(([value, label]) => (
707 <SelectItem key={value} value={value}>
708 {label}
709 </SelectItem>
710 ))}
711 </SelectContent>
712 </Select>
713 </div>
714 ))}
715 </div>
716 </FormSection>
717
718 <FormSection title="Capacity" about="How many sessions it works on at once. More wait in line, queued.">
719 <div className="max-w-40">
720 <Label htmlFor="capacity" error={e.capacity}>
721 Sessions at once
722 </Label>
723 <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} />
724 </div>
725 </FormSection>
726
727 <div className="sticky bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur sm:-mx-8 sm:px-8">
728 {Object.keys(e).length > 0 && <p className="mr-auto text-sm text-danger">Check the fields marked above.</p>}
729 <button
730 type="submit"
731 disabled={busy}
732 className="inline-flex h-9 items-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-60"
733 >
734 <Sparkles size={15} />
735 {busy ? "Saving…" : submit}
736 </button>
737 </div>
738 </Form>
739 );
740}