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