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