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