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