Skip to content
503 linesCodeBlameRaw
1import { Activity, ChevronRight, Plus, Route as RouteIcon, Sparkles } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router";
4
5import type { AgentTemplate, ModelTier, WorkspaceAgent } from "@g1t/contracts";
6
7import { AgentMark, StatusDot, statusLabel } from "./chat/marks";
8import { Avatar } from "./ui";
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
10import { Skeleton } from "./ui/skeleton";
11import { AUTONOMY, type AgentDraft, PRESETS, TIER_LABELS, dollarsField } from "../lib/agent-form";
12import type { AgentsLayoutData } from "../routes/workspace/agents/layout";
13
14/** The workspace's agents while an Agents page is open; undefined elsewhere. */
15export function useAgentsData(): AgentsLayoutData | undefined {
16 return useRouteLoaderData("routes/workspace/agents/layout") as AgentsLayoutData | undefined;
17}
18
19/** An agent's face: its picture, or the sparkle mark. */
20export function AgentFace({ agent, size = 20 }: { agent: Pick<WorkspaceAgent, "handle" | "avatar">; size?: number }) {
21 return agent.avatar ? <Avatar name={agent.handle} image={agent.avatar} size={size} square /> : <AgentMark size={size} />;
22}
23
24function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
25 return (
26 <NavLink
27 to={to}
28 end={end}
29 prefetch="intent"
30 className={({ isActive }) =>
31 `group flex h-9 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
32 isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
33 }`
34 }
35 >
36 <span className="flex w-5 shrink-0 justify-center">{icon}</span>
37 <span className="min-w-0 grow truncate">{children}</span>
38 {trailing}
39 </NavLink>
40 );
41}
42
43/**
44 * Agents mode's sidebar: who is working, who is waiting on you, and every
45 * agent, each with its status; and the way to make a new one.
46 */
47export function AgentsSidebar({ slug }: { slug: string }) {
48 const data = useAgentsData();
49 const agents = data?.agents ?? [];
50 const busy = agents.filter((a) => a.status === "working" || a.status === "waiting");
51 const row = (agent: WorkspaceAgent) => (
52 <li key={agent.id}>
53 <NavLink
54 to={`/${slug}/-/agents/${agent.handle}`}
55 prefetch="intent"
56 className={({ isActive }) =>
57 `group flex h-11 items-center gap-2.5 rounded-md px-2 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
58 }
59 >
60 <span className="relative shrink-0">
61 <AgentFace agent={agent} size={26} />
62 <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]" />
63 </span>
64 <span className="min-w-0 grow leading-tight">
65 <span className="block truncate text-[0.8125rem] font-medium text-fg">{agent.display_name}</span>
66 <span className="block truncate text-[0.6875rem] text-faint">
67 {agent.status === "idle" ? `@${agent.handle}` : statusLabel(agent.status)}
68 </span>
69 </span>
70 </NavLink>
71 </li>
72 );
73 return (
74 <div className="flex h-full flex-col">
75 <div className="flex h-14 shrink-0 items-center justify-between border-b border-line pr-2.5 pl-4">
76 <h2 className="text-[0.9375rem] font-semibold">Agents</h2>
77 <NavLink
78 to={`/${slug}/-/agents/new`}
79 aria-label="New agent"
80 className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
81 >
82 <Plus size={16} />
83 </NavLink>
84 </div>
85 <nav aria-label="Agents" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
86 <div className="space-y-px">
87 <SideLink to={`/${slug}/-/agents`} end icon={<Activity size={15} className="text-faint" />}>
88 Fleet and runs
89 </SideLink>
90 </div>
91 {!data ? (
92 <div className="mt-5 space-y-3 px-2" aria-busy="true">
93 {[0, 1, 2].map((i) => (
94 <div key={i} className="flex items-center gap-2.5">
95 <Skeleton className="size-6 rounded-md" />
96 <Skeleton className="h-3 w-28" />
97 </div>
98 ))}
99 </div>
100 ) : data.agents == null ? (
101 <p className="mt-4 px-2 text-xs leading-relaxed text-faint">The agents service didn't answer. Your agents will show here once it does.</p>
102 ) : (
103 <>
104 {busy.length > 0 && (
105 <Group title="At work">
106 {busy.map(row)}
107 </Group>
108 )}
109 <Group title={`All agents · ${agents.length}`}>
110 {agents.map(row)}
111 {agents.length === 0 && <li className="px-2 py-1 text-xs text-faint">None yet. Make one from a template.</li>}
112 </Group>
113 </>
114 )}
115 <NavLink
116 to={`/${slug}/-/agents/new`}
117 className={({ isActive }) =>
118 `mt-2 flex h-9 items-center gap-2.5 rounded-md border border-dashed px-2 text-[0.8125rem] transition-colors ${
119 isActive ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
120 }`
121 }
122 >
123 <span className="flex w-5 justify-center">
124 <Plus size={15} />
125 </span>
126 New agent
127 </NavLink>
128 </nav>
129 </div>
130 );
131}
132
133function Group({ title, children }: { title: string; children: ReactNode }) {
134 return (
135 <section className="mt-4">
136 <h3 className="mb-1 px-2 text-xs font-medium text-faint">{title}</h3>
137 <ul className="space-y-px">{children}</ul>
138 </section>
139 );
140}
141
142/** The templates g1t ships, and a blank one, to start a new agent from. */
143export function TemplateGallery({ templates, chosen, onChoose }: { templates: AgentTemplate[]; chosen: string | null; onChoose: (id: string | null) => void }) {
144 return (
145 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
146 {templates.map((template) => (
147 <TemplateCard key={template.id} selected={chosen === template.id} onClick={() => onChoose(template.id)} title={template.display_name} handle={template.handle}>
148 {template.role}
149 </TemplateCard>
150 ))}
151 <TemplateCard selected={chosen === "blank"} onClick={() => onChoose("blank")} title="Blank agent" blank>
152 Start from nothing: give it a name, a job and a voice.
153 </TemplateCard>
154 </div>
155 );
156}
157
158function TemplateCard({
159 selected,
160 onClick,
161 title,
162 handle,
163 blank,
164 children,
165}: {
166 selected: boolean;
167 onClick: () => void;
168 title: string;
169 handle?: string;
170 blank?: boolean;
171 children: ReactNode;
172}) {
173 return (
174 <button
175 type="button"
176 onClick={onClick}
177 aria-pressed={selected}
178 className={`group flex flex-col items-start rounded-xl border p-4 text-left transition-all ${
179 selected
180 ? "border-accent/60 bg-accent/[0.07] ring-1 ring-accent/40"
181 : "border-line bg-surface hover:-translate-y-px hover:border-line-strong hover:bg-raised/40"
182 }`}
183 >
184 <span className="flex w-full items-center gap-2.5">
185 {blank ? (
186 <span className="flex size-9 items-center justify-center rounded-[10px] border border-dashed border-line-strong text-muted">
187 <Plus size={16} />
188 </span>
189 ) : (
190 <AgentMark size={36} />
191 )}
192 <span className="min-w-0 grow">
193 <span className="block truncate text-sm font-semibold">{title}</span>
194 {handle && <span className="block truncate font-mono text-xs text-faint">@{handle}</span>}
195 </span>
196 <ChevronRight size={15} className={`shrink-0 transition-colors ${selected ? "text-accent" : "text-faint group-hover:text-muted"}`} />
197 </span>
198 <span className="mt-3 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{children}</span>
199 </button>
200 );
201}
202
203const 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";
204
205function Label({ children, hint, error, htmlFor }: { children: ReactNode; hint?: ReactNode; error?: string; htmlFor?: string }) {
206 return (
207 <div className="mb-1.5 flex items-baseline justify-between gap-3">
208 <label htmlFor={htmlFor} className="text-sm font-medium text-fg-soft">
209 {children}
210 </label>
211 {error ? <span className="text-xs text-danger">{error}</span> : hint ? <span className="text-xs text-faint">{hint}</span> : null}
212 </div>
213 );
214}
215
216/** A section of the form: what it is on the left, its fields on the right, on a wide screen. */
217function FormSection({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
218 return (
219 <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]">
220 <div>
221 <h2 className="text-sm font-semibold">{title}</h2>
222 <p className="mt-1 text-[0.8125rem] leading-relaxed text-muted">{about}</p>
223 </div>
224 <div className="min-w-0 space-y-5">{children}</div>
225 </section>
226 );
227}
228
229function TierSelect({ name, value, none }: { name: string; value: ModelTier | null; none: string }) {
230 return (
231 <Select name={name} defaultValue={value ?? "none"}>
232 <SelectTrigger id={name}>
233 <SelectValue />
234 </SelectTrigger>
235 <SelectContent>
236 <SelectItem value="none">{none}</SelectItem>
237 {(["small", "large", "frontier"] as const).map((tier) => (
238 <SelectItem key={tier} value={tier} description={TIER_LABELS[tier].split(": ")[1]}>
239 {TIER_LABELS[tier].split(":")[0]}
240 </SelectItem>
241 ))}
242 </SelectContent>
243 </Select>
244 );
245}
246
247function Money({ name, value, label, hint, error }: { name: string; value: number | null; label: string; hint: string; error?: string }) {
248 return (
249 <div>
250 <Label htmlFor={name} hint={hint} error={error}>
251 {label}
252 </Label>
253 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
254 <span className="pl-3 text-sm text-faint">$</span>
255 <input
256 id={name}
257 name={name}
258 inputMode="decimal"
259 defaultValue={dollarsField(value)}
260 placeholder="No cap"
261 autoComplete="off"
262 data-1p-ignore
263 className="min-w-0 grow bg-transparent px-2 py-2 text-sm tabular-nums outline-none placeholder:text-faint"
264 />
265 </div>
266 </div>
267 );
268}
269
270/**
271 * Everything that defines an agent, for making one and for its Profile:
272 * who it is, its job, its voice, the limits on routing, its budget, what it
273 * may do alone, and how much it takes on at once.
274 */
275export function AgentForm({
276 draft,
277 errors,
278 submit,
279 intent,
280 formKey,
281}: {
282 draft: AgentDraft;
283 errors?: Record<string, string>;
284 submit: string;
285 intent: string;
286 formKey?: string;
287}) {
288 const navigation = useNavigation();
289 const { pathname } = useLocation();
290 const busy = navigation.state === "submitting" && navigation.formAction === pathname;
291 const [preset, setPreset] = useState(draft.personality_preset);
292 const [advanced, setAdvanced] = useState(Boolean(draft.routing.pinned));
293 const e = errors ?? {};
294 const presetAbout = PRESETS.find((p) => p.value === preset)?.about;
295 return (
296 <Form method="post" key={formKey} className="pb-24">
297 <input type="hidden" name="intent" value={intent} />
298 <input type="hidden" name="template" value={draft.template ?? ""} />
299 <FormSection title="Identity" about="How it shows in chat, mentions, assignees and the audit log.">
300 <div className="grid gap-5 sm:grid-cols-2">
301 <div>
302 <Label htmlFor="display_name" error={e.display_name}>
303 Display name
304 </Label>
305 <input id="display_name" name="display_name" defaultValue={draft.display_name} placeholder="Ship" className={FIELD} autoComplete="off" data-1p-ignore />
306 </div>
307 <div>
308 <Label htmlFor="handle" error={e.handle} hint="Mentioned as @handle">
309 Handle
310 </Label>
311 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
312 <span className="pl-3 font-mono text-sm text-faint">@</span>
313 <input
314 id="handle"
315 name="handle"
316 defaultValue={draft.handle}
317 placeholder="ship"
318 autoComplete="off"
319 data-1p-ignore
320 className="min-w-0 grow bg-transparent py-2 pr-3 pl-0.5 font-mono text-sm outline-none placeholder:text-faint"
321 />
322 </div>
323 </div>
324 </div>
325 <div>
326 <Label htmlFor="role" error={e.role} hint="One line">
327 Role
328 </Label>
329 <input id="role" name="role" defaultValue={draft.role} placeholder="Release manager for g1t" className={FIELD} autoComplete="off" data-1p-ignore />
330 </div>
331 </FormSection>
332
333 <FormSection title="Job" about="What it is responsible for, how it works, and what good looks like. It reads this before every reply and task.">
334 <div>
335 <Label htmlFor="instructions" error={e.instructions}>
336 Instructions
337 </Label>
338 <textarea
339 id="instructions"
340 name="instructions"
341 defaultValue={draft.instructions}
342 rows={9}
343 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."}
344 className={`${FIELD} resize-y font-mono text-[0.8125rem] leading-relaxed`}
345 />
346 </div>
347 </FormSection>
348
349 <FormSection title="Personality" about="Its voice only: tone, length, how it asks. A personality never changes what it may do.">
350 <div>
351 <input type="hidden" name="personality_preset" value={preset} />
352 <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">
353 {PRESETS.map((option) => (
354 <button
355 key={option.value}
356 type="button"
357 role="radio"
358 aria-checked={preset === option.value}
359 onClick={() => setPreset(option.value)}
360 className={`h-8 rounded-md text-[0.8125rem] font-medium transition-colors ${
361 preset === option.value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
362 }`}
363 >
364 {option.label}
365 </button>
366 ))}
367 </div>
368 <p className="mt-2 text-xs text-muted">{presetAbout}</p>
369 </div>
370 <div>
371 <Label htmlFor="personality" hint="Optional">
372 In its own words
373 </Label>
374 <textarea
375 id="personality"
376 name="personality"
377 defaultValue={draft.personality}
378 rows={3}
379 placeholder="Dry humor is fine. No emoji. Asks one question at a time."
380 className={`${FIELD} resize-y`}
381 />
382 </div>
383 </FormSection>
384
385 <FormSection
386 title="Models"
387 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."
388 >
389 <div className="flex items-start gap-3 rounded-xl border border-accent/25 bg-accent/[0.06] p-4">
390 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent">
391 <RouteIcon size={16} />
392 </span>
393 <div>
394 <p className="text-sm font-semibold">Auto: routed per step by need</p>
395 <p className="mt-0.5 text-[0.8125rem] leading-relaxed text-muted">
396 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.
397 </p>
398 </div>
399 </div>
400 <div className="grid gap-5 sm:grid-cols-3">
401 <div>
402 <Label htmlFor="floor">Floor</Label>
403 <TierSelect name="floor" value={draft.routing.floor} none="No floor" />
404 </div>
405 <div>
406 <Label htmlFor="ceiling" error={e.ceiling}>
407 Ceiling
408 </Label>
409 <TierSelect name="ceiling" value={draft.routing.ceiling} none="No ceiling" />
410 </div>
411 <div>
412 <Label htmlFor="providers">Providers</Label>
413 <Select name="providers" defaultValue={draft.routing.providers[0] ?? "any"}>
414 <SelectTrigger id="providers">
415 <SelectValue />
416 </SelectTrigger>
417 <SelectContent>
418 <SelectItem value="any" description="g1t's hosted models and the workspace's own">
419 Any the workspace allows
420 </SelectItem>
421 <SelectItem value="g1t" description="Never the workspace's own keys">
422 g1t's hosted models
423 </SelectItem>
424 <SelectItem value="workspace" description="Set up under Integrations; billed by the provider">
425 The workspace's own providers
426 </SelectItem>
427 </SelectContent>
428 </Select>
429 </div>
430 </div>
431 <div className="rounded-lg border border-line">
432 <button
433 type="button"
434 onClick={() => setAdvanced(!advanced)}
435 aria-expanded={advanced}
436 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"
437 >
438 <ChevronRight size={14} className={`transition-transform ${advanced ? "rotate-90" : ""}`} />
439 Advanced
440 <span className="font-normal text-faint">Pin one model</span>
441 </button>
442 <div hidden={!advanced} className="border-t border-line px-3.5 py-3.5">
443 <Label htmlFor="pinned" error={e.pinned} hint="For your own endpoints. Turns routing off.">
444 Pinned model
445 </Label>
446 <input id="pinned" name="pinned" defaultValue={draft.routing.pinned ?? ""} placeholder="provider/model" className={`${FIELD} font-mono`} autoComplete="off" data-1p-ignore />
447 </div>
448 </div>
449 </FormSection>
450
451 <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.">
452 <div className="grid gap-5 sm:grid-cols-3">
453 <Money name="monthly" label="Monthly" hint="Per calendar month" value={draft.budget.monthly_micros} error={e.monthly} />
454 <Money name="daily" label="Daily" hint="Optional" value={draft.budget.daily_micros} error={e.daily} />
455 <Money name="task" label="Per task" hint="Going over asks" value={draft.budget.task_micros} error={e.task} />
456 </div>
457 </FormSection>
458
459 <FormSection title="Autonomy" about="What it may do alone and what needs a person. Rulesets and branch protection still apply on top.">
460 <div className="grid gap-5 sm:grid-cols-2">
461 {(Object.keys(AUTONOMY) as (keyof typeof AUTONOMY)[]).map((key) => (
462 <div key={key}>
463 <Label htmlFor={key}>{AUTONOMY[key].label}</Label>
464 <Select name={key} defaultValue={draft.autonomy[key]}>
465 <SelectTrigger id={key}>
466 <SelectValue />
467 </SelectTrigger>
468 <SelectContent>
469 {AUTONOMY[key].options.map(([value, label]) => (
470 <SelectItem key={value} value={value}>
471 {label}
472 </SelectItem>
473 ))}
474 </SelectContent>
475 </Select>
476 </div>
477 ))}
478 </div>
479 </FormSection>
480
481 <FormSection title="Capacity" about="How many tasks it works at once. More wait on its desk, each showing its place in line.">
482 <div className="max-w-40">
483 <Label htmlFor="capacity" error={e.capacity}>
484 Tasks at once
485 </Label>
486 <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} />
487 </div>
488 </FormSection>
489
490 <div className="sticky 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">
491 {Object.keys(e).length > 0 && <p className="mr-auto text-sm text-danger">Check the fields marked above.</p>}
492 <button
493 type="submit"
494 disabled={busy}
495 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"
496 >
497 <Sparkles size={15} />
498 {busy ? "Saving…" : submit}
499 </button>
500 </div>
501 </Form>
502 );
503}