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