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