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