Skip to content
833 linesCodeBlameRaw
1/**
2 * The agent builder (docs.g1t.sh/guides/agents/, "Describe it"): one box
3 * for what the agent should do, the drafted agent as a card to edit, a test
4 * chat beside it, and, on an agent's profile, a box to change it in words
5 * with the change shown before it is saved.
6 */
7import { ArrowUp, Check, Dices, Lock, MessageSquare, Plug, RotateCcw, Shuffle, Sparkles, Users, Wand2 } from "lucide-react";
8import { type ReactNode, useEffect, useRef, useState } from "react";
9import { Form, Link, useFetcher, useNavigation } from "react-router";
10
11import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts";
12
13import { TeamPicker } from "../agent-role";
14import { AgentAvatar, PixelCreature } from "../agent-avatar";
15import { Markdown } from "../markdown";
16import { Badge } from "../ui/badge";
17import { Checkbox } from "../ui/checkbox";
18import { Hint } from "../ui/hint";
19import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
20import { type BuilderDefinition, type ChangeRow, changeRows, integrationHint } from "../../lib/agent-builder";
21import { PRESETS, TIER_LABELS, cleanHandle, dollarsField, microsFromDollars } from "../../lib/agent-form";
22import { cn } from "../../lib/cn";
23import { money } from "../../lib/usage";
24
25const FIELD =
26 "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";
27const PRIMARY = "inline-flex h-9 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-60";
28const QUIET =
29 "inline-flex h-9 items-center justify-center gap-2 rounded-md border border-line px-3.5 text-sm font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg disabled:opacity-60";
30
31/** What the builder's actions answer. */
32export type BuilderAnswer =
33 | { intent: "draft"; ok: true; proposal: AgentProposal }
34 | { intent: "try"; ok: true; text: string; charged_micros: number }
35 | { intent: "redraft"; ok: true; redraft: AgentRedraft }
36 | { intent: string; ok: false; error: string };
37
38/** Things people ask agents to do, to start the box when it is empty. */
39const EXAMPLES = [
40 "Every Monday, read last week's merged pull requests and write release notes in our Releases space.",
41 "Triage new bug reports: reproduce them from the steps, label them, and ask for what's missing.",
42 "Keep me on top of my week: summarise what I'm mentioned in and what's waiting on me.",
43 "Review database migrations for locking, data loss and missing indexes before they merge.",
44];
45
46// ── Describe it ───────────────────────────────────────────────────────────
47
48/**
49 * The first thing New agent shows: one box, "What should this agent do?",
50 * the roles to start from under it, and the full form for those who want it.
51 */
52export function DescribeBox({
53 slug,
54 owner,
55 mayCreate,
56 templates,
57 fetcher,
58 initial,
59}: {
60 slug: string;
61 owner: boolean;
62 /** Whether the viewer may create an agent here at all. */
63 mayCreate: boolean;
64 templates: AgentTemplate[];
65 fetcher: ReturnType<typeof useFetcher<BuilderAnswer>>;
66 initial?: string;
67}) {
68 const [text, setText] = useState(initial ?? "");
69 const [scope, setScope] = useState<"workspace" | "personal">(owner ? "workspace" : "personal");
70 const busy = fetcher.state !== "idle";
71 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "draft" ? fetcher.data.error : null;
72 return (
73 <div>
74 <fetcher.Form method="post" className="relative">
75 <input type="hidden" name="intent" value="draft" />
76 <input type="hidden" name="scope" value={scope} />
77 <label htmlFor="description" className="sr-only">
78 What should this agent do?
79 </label>
80 <div
81 className={cn(
82 "rounded-2xl border border-line bg-surface p-1.5 shadow-sm transition-colors focus-within:border-accent/60 focus-within:ring-4 focus-within:ring-accent/10",
83 !mayCreate && "opacity-60",
84 )}
85 >
86 <textarea
87 id="description"
88 name="description"
89 value={text}
90 onChange={(event) => setText(event.target.value)}
91 onKeyDown={(event) => {
92 if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && text.trim().length >= 3) event.currentTarget.form?.requestSubmit();
93 }}
94 disabled={!mayCreate || busy}
95 rows={4}
96 maxLength={2000}
97 placeholder="Describe the job: what it does, for whom, and what good looks like."
98 className="block w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-base leading-relaxed outline-none placeholder:text-faint sm:text-[0.9375rem]"
99 />
100 <div className="flex flex-wrap items-center gap-2 px-1.5 pb-1">
101 {owner ? (
102 <div role="radiogroup" aria-label="Who it's for" className="flex rounded-lg bg-bg p-0.5 ring-1 ring-line">
103 {(
104 [
105 ["workspace", "The workspace", <Users key="w" size={13} />],
106 ["personal", "Just me", <Lock key="p" size={13} />],
107 ] as const
108 ).map(([value, label, icon]) => (
109 <button
110 key={value}
111 type="button"
112 role="radio"
113 aria-checked={scope === value}
114 onClick={() => setScope(value)}
115 className={cn(
116 "inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
117 scope === value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg",
118 )}
119 >
120 {icon}
121 {label}
122 </button>
123 ))}
124 </div>
125 ) : (
126 <span className="inline-flex items-center gap-1.5 text-xs text-muted">
127 <Lock size={12} />
128 Your personal agent
129 </span>
130 )}
131 <span className="grow" />
132 <span className="hidden text-xs text-faint sm:inline">{busy ? "Drafting…" : "Ctrl+Enter"}</span>
133 <button type="submit" disabled={!mayCreate || busy || text.trim().length < 3} className={cn(PRIMARY, "h-8 px-3")}>
134 <Sparkles size={14} className={busy ? "animate-pulse motion-reduce:animate-none" : ""} />
135 {busy ? "Drafting…" : "Draft it"}
136 </button>
137 </div>
138 </div>
139 </fetcher.Form>
140 {error && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger">{error}</p>}
141 <p className="mt-3 text-xs leading-relaxed text-muted">
142 {scope === "personal"
143 ? "Only you can talk to it, in your direct message with it. It spends from your own budget: $20 a month and $2 a session to start."
144 : "Everyone in the workspace can talk to it. It starts with the workspace's budget for a new agent."}{" "}
145 Drafting is a quick model call charged to you; nothing is saved until you create it.
146 </p>
147 {!text && mayCreate && (
148 <div className="mt-5">
149 <p className="text-xs font-medium text-faint">For example</p>
150 <ul className="mt-2 grid gap-2 sm:grid-cols-2">
151 {EXAMPLES.map((example) => (
152 <li key={example}>
153 <button
154 type="button"
155 onClick={() => setText(example)}
156 className="w-full rounded-lg border border-line bg-bg px-3 py-2 text-left text-[0.8125rem] leading-snug text-muted transition-colors hover:border-line-strong hover:text-fg"
157 >
158 {example}
159 </button>
160 </li>
161 ))}
162 </ul>
163 </div>
164 )}
165 {templates.length > 0 && (
166 <div className="mt-8">
167 <p className="text-xs font-medium text-faint">or start from a role</p>
168 <ul className="mt-2 flex flex-wrap gap-2">
169 {templates.map((template) => (
170 <li key={template.id}>
171 <Link
172 to={`?template=${encodeURIComponent(template.id)}`}
173 preventScrollReset
174 className="inline-flex h-8 items-center gap-2 rounded-full border border-line bg-surface pr-3 pl-1 text-[0.8125rem] text-fg/85 transition-colors hover:border-line-strong hover:text-fg"
175 >
176 <PixelCreature seed={template.handle} size={24} className="rounded-full" />
177 {template.title}
178 </Link>
179 </li>
180 ))}
181 <li>
182 <Link
183 to="?template=blank"
184 preventScrollReset
185 className="inline-flex h-8 items-center gap-1.5 rounded-full border border-dashed border-line-strong px-3 text-[0.8125rem] text-muted transition-colors hover:text-fg"
186 >
187 Edit all fields
188 </Link>
189 </li>
190 </ul>
191 <p className="mt-3 text-xs text-faint">
192 <Link to={`/${slug}/-/agents/templates`} className="hover:text-fg hover:underline">
193 Read each role in full
194 </Link>
195 </p>
196 </div>
197 )}
198 </div>
199 );
200}
201
202// ── The drafted agent ─────────────────────────────────────────────────────
203
204function Label({ htmlFor, children, hint }: { htmlFor?: string; children: ReactNode; hint?: ReactNode }) {
205 return (
206 <div className="mb-1.5 flex items-baseline justify-between gap-3">
207 <label htmlFor={htmlFor} className="text-xs font-medium text-fg-soft">
208 {children}
209 </label>
210 {hint && <span className="text-xs text-faint">{hint}</span>}
211 </div>
212 );
213}
214
215function Section({ title, about, children }: { title: string; about?: ReactNode; children: ReactNode }) {
216 return (
217 <section className="border-t border-line px-4 py-4 sm:px-5">
218 <h3 className="text-xs font-semibold tracking-wide text-faint uppercase">{title}</h3>
219 {about && <p className="mt-1 text-xs text-muted">{about}</p>}
220 <div className="mt-3 space-y-3">{children}</div>
221 </section>
222 );
223}
224
225/**
226 * The drafted agent as a card to edit: who it is, its job, what it is
227 * responsible for, its voice, the skills it keeps on, what it needs
228 * connected, routines that would suit it, its model limits and budget.
229 * Only the name and the job are required.
230 */
231export function ProposalCard({
232 slug,
233 owner,
234 proposal,
235 value,
236 onChange,
237 joinable = [],
238 teams = [],
239 onTeams,
240}: {
241 slug: string;
242 owner: boolean;
243 proposal: AgentProposal;
244 value: BuilderDefinition;
245 onChange: (next: BuilderDefinition) => void;
246 /** The teams the person may add it to; a workspace agent only. */
247 joinable?: { slug: string; name: string }[];
248 /** The teams chosen to add it to: memberships, posted with Create, never part of the draft. */
249 teams?: string[];
250 onTeams?: (next: string[]) => void;
251}) {
252 const ideas = [proposal.definition.display_name, ...proposal.name_ideas];
253 const [idea, setIdea] = useState(0);
254 const [handleEdited, setHandleEdited] = useState(false);
255 const set = (patch: Partial<BuilderDefinition>) => onChange({ ...value, ...patch });
256 const rename = (name: string) => set({ display_name: name, ...(handleEdited ? {} : { handle: cleanHandle(name) }) });
257 const off = new Set(value.skills_off ?? []);
258 const suggested = new Set(proposal.skills);
259 const hints = proposal.integrations.map((s) => integrationHint(slug, s, owner)).filter((h) => h !== null);
260 const seed = value.avatar_seed || value.handle || "agent";
261 return (
262 <div className="overflow-hidden rounded-2xl border border-line bg-surface">
263 <div className="flex items-start gap-3 px-4 pt-4 pb-4 sm:px-5">
264 <div className="relative shrink-0">
265 <AgentAvatar agent={{ handle: value.handle, avatar_seed: seed }} size={56} />
266 <Hint label="Another face">
267 <button
268 type="button"
269 aria-label="Another face"
270 onClick={() => set({ avatar_seed: `${value.handle || "agent"}-${Math.random().toString(36).slice(2, 7)}` })}
271 className="absolute -right-1.5 -bottom-1.5 flex size-6 items-center justify-center rounded-full border border-line bg-bg text-muted shadow-sm transition-colors hover:text-fg"
272 >
273 <Dices size={13} />
274 </button>
275 </Hint>
276 </div>
277 <div className="grid min-w-0 grow gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
278 <div>
279 <Label htmlFor="b-name">Name</Label>
280 <div className="flex gap-1.5">
281 <input id="b-name" value={value.display_name} onChange={(e) => rename(e.target.value)} className={FIELD} autoComplete="off" data-1p-ignore required />
282 {ideas.length > 1 && (
283 <Hint label="Another name">
284 <button
285 type="button"
286 aria-label="Another name"
287 onClick={() => {
288 const next = (idea + 1) % ideas.length;
289 setIdea(next);
290 rename(ideas[next]!);
291 }}
292 className="flex size-9.5 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"
293 >
294 <Shuffle size={15} />
295 </button>
296 </Hint>
297 )}
298 </div>
299 </div>
300 <div>
301 <Label htmlFor="b-handle">Handle</Label>
302 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
303 <span className="pl-3 font-mono text-sm text-faint">@</span>
304 <input
305 id="b-handle"
306 value={value.handle}
307 onChange={(e) => {
308 setHandleEdited(true);
309 set({ handle: e.target.value });
310 }}
311 className="min-w-0 grow bg-transparent py-2 pr-3 pl-0.5 font-mono text-sm outline-none"
312 autoComplete="off"
313 data-1p-ignore
314 />
315 </div>
316 </div>
317 <div>
318 <Label htmlFor="b-title">Title</Label>
319 <input id="b-title" value={value.title ?? ""} onChange={(e) => set({ title: e.target.value })} placeholder="Release Manager" className={FIELD} autoComplete="off" />
320 </div>
321 </div>
322 </div>
323
324 {onTeams && (
325 <Section title="Add to teams" about={value.scope === "personal" ? "A personal agent is on no team." : "Optional. It joins them like anyone, and you can change them on its profile or the team's page."}>
326 {value.scope === "personal" ? null : <TeamPicker teams={joinable} value={teams} onChange={onTeams} />}
327 </Section>
328 )}
329
330 <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply.">
331 <textarea
332 aria-label="Job"
333 value={value.instructions}
334 onChange={(e) => set({ instructions: e.target.value })}
335 rows={8}
336 required
337 className={`${FIELD} resize-y font-mono text-[0.8125rem] leading-relaxed`}
338 />
339 <div>
340 <Label htmlFor="b-duties" hint="One a line, 2 to 8">
341 Responsibilities
342 </Label>
343 <textarea
344 id="b-duties"
345 value={(value.responsibilities ?? []).join("\n")}
346 onChange={(e) => set({ responsibilities: e.target.value.split("\n").slice(0, 8) })}
347 onBlur={() => set({ responsibilities: (value.responsibilities ?? []).map((d) => d.trim()).filter(Boolean) })}
348 rows={Math.max(3, (value.responsibilities ?? []).length)}
349 className={`${FIELD} resize-y`}
350 />
351 </div>
352 </Section>
353
354 <Section title="Personality" about="Its voice only. A personality never changes what it may do.">
355 <div role="radiogroup" aria-label="Voice" className="grid grid-cols-2 gap-1 rounded-lg bg-bg p-1 ring-1 ring-line sm:grid-cols-4">
356 {PRESETS.map((option) => (
357 <button
358 key={option.value}
359 type="button"
360 role="radio"
361 aria-checked={value.personality_preset === option.value}
362 onClick={() => set({ personality_preset: option.value })}
363 className={cn(
364 "h-8 rounded-md text-[0.8125rem] font-medium transition-colors",
365 value.personality_preset === option.value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg",
366 )}
367 >
368 {option.label}
369 </button>
370 ))}
371 </div>
372 <input aria-label="In its own words" value={value.personality ?? ""} onChange={(e) => set({ personality: e.target.value })} placeholder="In its own words (optional)" className={FIELD} />
373 </Section>
374
375 <Section title="Skills" about="g1t's playbooks it keeps on. The suggested ones fit its job; turn any on or off.">
376 <div className="grid gap-1.5 sm:grid-cols-2">
377 {FOUNDATIONAL_SKILLS.map((skill) => {
378 const on = !off.has(skill.id);
379 return (
380 <label
381 key={skill.id}
382 className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5"
383 >
384 <Checkbox
385 checked={on}
386 onCheckedChange={() => {
387 const next = new Set(off);
388 if (on) next.add(skill.id);
389 else next.delete(skill.id);
390 set({ skills_off: FOUNDATIONAL_SKILLS.map((s) => s.id).filter((id) => next.has(id)) });
391 }}
392 />
393 <span className="min-w-0 grow truncate">{skill.name}</span>
394 {suggested.has(skill.id) && suggested.size < FOUNDATIONAL_SKILLS.length && (
395 <Badge tone="accent" className="shrink-0">
396 Suggested
397 </Badge>
398 )}
399 </label>
400 );
401 })}
402 </div>
403 </Section>
404
405 {hints.length > 0 && (
406 <Section title="What it needs connected" about="Integrations its job would use. It works without them, and says so when asked for something that needs one.">
407 <ul className="divide-y divide-line overflow-hidden rounded-lg border border-line bg-bg">
408 {hints.map((hint) => (
409 <li key={hint.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2.5">
410 <Plug size={14} className="shrink-0 text-faint" />
411 <span className="min-w-0 grow basis-40">
412 <span className="block text-sm font-medium">{hint.name}</span>
413 {hint.why && <span className="block text-xs text-muted">{hint.why}</span>}
414 </span>
415 {hint.action === "soon" ? (
416 <Badge>Soon</Badge>
417 ) : (
418 <Link to={hint.href} target="_blank" rel="noreferrer" className="text-xs font-medium text-accent hover:underline">
419 {hint.action === "connect" ? "Connect" : "Ask an owner"}
420 </Link>
421 )}
422 </li>
423 ))}
424 </ul>
425 </Section>
426 )}
427
428 {proposal.routines.length > 0 && (
429 <Section
430 title="Routines that would suit it"
431 about={value.scope === "personal" ? "Personal agents can't run routines yet. An owner can promote it to a workspace agent, which can." : "Set them up on its Routines tab once it exists."}
432 >
433 <ul className="space-y-2">
434 {proposal.routines.map((routine) => (
435 <li key={routine.name} className="rounded-lg border border-line bg-bg px-3 py-2.5">
436 <p className="text-sm font-medium">
437 {routine.name} <span className="font-normal text-muted">· {routine.when}</span>
438 </p>
439 {routine.instructions && <p className="mt-0.5 text-xs text-muted">{routine.instructions}</p>}
440 </li>
441 ))}
442 </ul>
443 </Section>
444 )}
445
446 <Section title="Models and budget" about="g1t routes each step to the tier it needs; these only limit it. Budgets are checked before work starts.">
447 <div className="grid gap-3 sm:grid-cols-2">
448 {(["floor", "ceiling"] as const).map((key) => (
449 <div key={key}>
450 <Label htmlFor={`b-${key}`}>{key === "floor" ? "Floor" : "Ceiling"}</Label>
451 <Select
452 value={value.routing?.[key] ?? "none"}
453 onValueChange={(tier) =>
454 set({ routing: { floor: null, ceiling: null, providers: [], pinned: null, ...value.routing, [key]: tier === "none" ? null : (tier as "small") } })
455 }
456 >
457 <SelectTrigger id={`b-${key}`}>
458 <SelectValue />
459 </SelectTrigger>
460 <SelectContent>
461 <SelectItem value="none">{key === "floor" ? "No floor" : "No ceiling"}</SelectItem>
462 {(["small", "large", "frontier"] as const).map((tier) => (
463 <SelectItem key={tier} value={tier} description={TIER_LABELS[tier].split(": ")[1]}>
464 {TIER_LABELS[tier].split(":")[0]}
465 </SelectItem>
466 ))}
467 </SelectContent>
468 </Select>
469 </div>
470 ))}
471 {(
472 [
473 ["monthly_micros", "Monthly"],
474 ["task_micros", "Per session"],
475 ] as const
476 ).map(([key, label]) => (
477 <div key={key}>
478 <Label htmlFor={`b-${key}`} hint={key === "monthly_micros" ? "Per calendar month" : "Going over asks you"}>
479 {label}
480 </Label>
481 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
482 <span className="pl-3 text-sm text-faint">$</span>
483 <input
484 id={`b-${key}`}
485 inputMode="decimal"
486 defaultValue={dollarsField(value.budget?.[key])}
487 onBlur={(e) => {
488 const micros = microsFromDollars(e.target.value);
489 set({ budget: { ...value.budget, [key]: micros == null || Number.isNaN(micros) ? null : micros } });
490 }}
491 placeholder="No cap"
492 className="min-w-0 grow bg-transparent px-2 py-2 text-sm tabular-nums outline-none placeholder:text-faint"
493 autoComplete="off"
494 data-1p-ignore
495 />
496 </div>
497 </div>
498 ))}
499 </div>
500 </Section>
501 </div>
502 );
503}
504
505// ── Try it ────────────────────────────────────────────────────────────────
506
507/**
508 * A test chat with the drafted agent, as it is in the card right now:
509 * charged to the person trying it, and nothing kept once they leave.
510 */
511export function TryChat({ definition, onReset }: { definition: BuilderDefinition; onReset?: () => void }) {
512 const fetcher = useFetcher<BuilderAnswer>({ key: "agent-try" });
513 const [turns, setTurns] = useState<DraftTurn[]>([]);
514 const [text, setText] = useState("");
515 const [spent, setSpent] = useState(0);
516 const handled = useRef<unknown>(null);
517 const log = useRef<HTMLDivElement>(null);
518 const busy = fetcher.state !== "idle";
519 useEffect(() => {
520 if (fetcher.state !== "idle" || !fetcher.data || handled.current === fetcher.data) return;
521 handled.current = fetcher.data;
522 if (fetcher.data.ok && fetcher.data.intent === "try") {
523 const answer = fetcher.data;
524 setTurns((now) => [...now, { role: "assistant", content: answer.text }]);
525 setSpent((n) => n + answer.charged_micros);
526 }
527 }, [fetcher.state, fetcher.data]);
528 // The newest message in view, scrolling only the conversation, never the page.
529 useEffect(() => {
530 if (log.current) log.current.scrollTop = log.current.scrollHeight;
531 }, [turns.length, busy]);
532 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "try" ? fetcher.data.error : null;
533 const send = () => {
534 const body = text.trim();
535 if (!body || busy) return;
536 const next: DraftTurn[] = [...turns, { role: "user", content: body }];
537 setTurns(next);
538 setText("");
539 fetcher.submit({ intent: "try", definition: JSON.stringify(definition), messages: JSON.stringify(next.slice(-24)) }, { method: "post" });
540 };
541 const name = definition.display_name || "your agent";
542 return (
543 <div className="flex h-full min-h-104 flex-col overflow-hidden rounded-2xl border border-line bg-surface">
544 <div className="flex items-center gap-2.5 border-b border-line px-4 py-3">
545 <MessageSquare size={15} className="text-faint" />
546 <div className="min-w-0 grow">
547 <p className="text-sm font-semibold">Try it</p>
548 <p className="truncate text-xs text-muted">Talk to {name} as it is drafted. Charged to you; nothing is saved.</p>
549 </div>
550 {turns.length > 0 && (
551 <Hint label="Start over">
552 <button
553 type="button"
554 aria-label="Start over"
555 onClick={() => {
556 setTurns([]);
557 onReset?.();
558 }}
559 className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
560 >
561 <RotateCcw size={14} />
562 </button>
563 </Hint>
564 )}
565 </div>
566 <div ref={log} className="min-h-0 grow space-y-3 overflow-y-auto px-4 py-4" aria-live="polite">
567 {turns.length === 0 && (
568 <div className="flex h-full flex-col items-center justify-center py-8 text-center">
569 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={40} />
570 <p className="mt-3 text-sm font-medium">Ask {name} something it would get asked</p>
571 <p className="mt-1 max-w-xs text-xs text-muted">It answers from its job and voice. It has no tools or memory until you create it.</p>
572 </div>
573 )}
574 {turns.map((turn, at) =>
575 turn.role === "user" ? (
576 <div key={at} className="flex justify-end">
577 <p className="max-w-[85%] rounded-2xl rounded-br-md bg-accent/15 px-3.5 py-2 text-sm whitespace-pre-wrap text-fg">{turn.content}</p>
578 </div>
579 ) : (
580 <div key={at} className="flex gap-2.5">
581 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={24} />
582 <div className="min-w-0 grow text-sm [&_.prose]:text-sm">
583 <Markdown source={turn.content} />
584 </div>
585 </div>
586 ),
587 )}
588 {busy && (
589 <div className="flex items-center gap-2.5 text-xs text-muted">
590 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={24} />
591 {name} is typing…
592 </div>
593 )}
594 {error && <p className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">{error}</p>}
595 </div>
596 <form
597 className="flex items-end gap-2 border-t border-line p-2.5"
598 onSubmit={(event) => {
599 event.preventDefault();
600 send();
601 }}
602 >
603 <label htmlFor="try-message" className="sr-only">
604 Message {name}
605 </label>
606 <textarea
607 id="try-message"
608 value={text}
609 onChange={(e) => setText(e.target.value)}
610 onKeyDown={(e) => {
611 if (e.key === "Enter" && !e.shiftKey) {
612 e.preventDefault();
613 send();
614 }
615 }}
616 rows={1}
617 maxLength={4000}
618 placeholder={`Message ${name}`}
619 className="max-h-32 min-h-9 min-w-0 grow resize-none rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
620 />
621 <button type="submit" aria-label="Send" disabled={busy || !text.trim()} className={cn(PRIMARY, "size-9 shrink-0 px-0")}>
622 <ArrowUp size={16} />
623 </button>
624 </form>
625 {spent > 0 && <p className="border-t border-line px-4 py-1.5 text-[0.6875rem] text-faint tabular-nums">Trying it has cost you {money(spent)} so far.</p>}
626 </div>
627 );
628}
629
630// ── Create ────────────────────────────────────────────────────────────────
631
632/** Saves the drafted agent: its hello is waiting in a direct message. */
633export function CreateDraft({ definition, teams = [], onEditAll }: { definition: BuilderDefinition; teams?: string[]; onEditAll: () => void }) {
634 const navigation = useNavigation();
635 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "create_draft";
636 const ready = definition.display_name.trim() && definition.instructions.trim() && definition.handle.trim();
637 return (
638 <Form method="post" className="flex flex-wrap items-center justify-end gap-2">
639 <input type="hidden" name="intent" value="create_draft" />
640 <input type="hidden" name="definition" value={JSON.stringify(definition)} />
641 {teams.map((slug) => (
642 <input key={slug} type="hidden" name="teams" value={slug} />
643 ))}
644 <button type="button" onClick={onEditAll} className={QUIET}>
645 Edit all fields
646 </button>
647 <button type="submit" disabled={busy || !ready} className={PRIMARY}>
648 <Check size={15} />
649 {busy ? "Creating…" : `Create ${definition.display_name.trim() || "agent"}`}
650 </button>
651 </Form>
652 );
653}
654
655// ── Change it in words ────────────────────────────────────────────────────
656
657/**
658 * "Tell <name> what to change": the change is drafted, shown field by
659 * field with the job as a diff, and saved as a new version only when the
660 * person says so.
661 */
662export function RedraftBox({ agent }: { agent: WorkspaceAgent }) {
663 const fetcher = useFetcher<BuilderAnswer>({ key: `redraft:${agent.id}` });
664 const save = useFetcher<{ ok?: boolean; saved?: boolean; errors?: Record<string, string> }>({ key: `redraft-save:${agent.id}` });
665 const [text, setText] = useState("");
666 const [shown, setShown] = useState<AgentRedraft | null>(null);
667 const handled = useRef<unknown>(null);
668 useEffect(() => {
669 if (fetcher.state !== "idle" || !fetcher.data || handled.current === fetcher.data) return;
670 handled.current = fetcher.data;
671 if (fetcher.data.ok && fetcher.data.intent === "redraft") setShown(fetcher.data.redraft);
672 }, [fetcher.state, fetcher.data]);
673 useEffect(() => {
674 if (save.state === "idle" && save.data?.saved) {
675 setShown(null);
676 setText("");
677 }
678 }, [save.state, save.data]);
679 const busy = fetcher.state !== "idle";
680 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "redraft" ? fetcher.data.error : null;
681 const saveError = save.state === "idle" && save.data && !save.data.saved ? (save.data.errors?.form ?? save.data.errors?.handle ?? null) : null;
682 const skillName = (id: string) => FOUNDATIONAL_SKILLS.find((s) => s.id === id)?.name ?? id;
683 const rows = shown ? changeRows(agent, shown.changes, skillName) : [];
684 return (
685 <section aria-labelledby="redraft" className="mb-10 rounded-2xl border border-line bg-surface">
686 <fetcher.Form method="post" className="p-4 sm:p-5">
687 <input type="hidden" name="intent" value="redraft" />
688 <h2 id="redraft" className="flex items-center gap-2 text-sm font-semibold">
689 <Wand2 size={15} className="text-accent" />
690 Tell {agent.display_name} what to change
691 </h2>
692 <p className="mt-1 text-xs text-muted">Say it in your own words. You'll see the change before it's saved as version {agent.version + 1}. Drafting it is charged to you.</p>
693 <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
694 <label htmlFor="redraft-request" className="sr-only">
695 What should change
696 </label>
697 <textarea
698 id="redraft-request"
699 name="request"
700 value={text}
701 onChange={(e) => setText(e.target.value)}
702 onKeyDown={(e) => {
703 if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && text.trim().length >= 3) e.currentTarget.form?.requestSubmit();
704 }}
705 rows={2}
706 maxLength={1000}
707 placeholder={`Answer in Spanish, and keep replies under five sentences`}
708 className={`${FIELD} grow resize-y`}
709 />
710 <button type="submit" disabled={busy || text.trim().length < 3} className={cn(PRIMARY, "shrink-0")}>
711 <Sparkles size={14} className={busy ? "animate-pulse motion-reduce:animate-none" : ""} />
712 {busy ? "Drafting…" : "Draft the change"}
713 </button>
714 </div>
715 {error && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
716 </fetcher.Form>
717 {shown && (
718 <div className="border-t border-line p-4 sm:p-5">
719 <p className="text-sm">
720 <span className="font-medium">{shown.summary}</span>{" "}
721 <span className="text-muted">
722 {rows.length} {rows.length === 1 ? "change" : "changes"} to version {shown.from_version}.
723 </span>
724 </p>
725 <ChangeList rows={rows} />
726 {saveError && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">{saveError}</p>}
727 <save.Form method="post" className="mt-4 flex flex-wrap items-center justify-end gap-2">
728 <input type="hidden" name="intent" value="apply" />
729 <input type="hidden" name="changes" value={JSON.stringify(shown.changes)} />
730 <input type="hidden" name="from_version" value={shown.from_version} />
731 <button type="button" onClick={() => setShown(null)} className={QUIET}>
732 Discard
733 </button>
734 <button type="submit" disabled={save.state !== "idle"} className={PRIMARY}>
735 <Check size={15} />
736 {save.state !== "idle" ? "Saving…" : `Save as version ${agent.version + 1}`}
737 </button>
738 </save.Form>
739 </div>
740 )}
741 </section>
742 );
743}
744
745/** Each field a change touches, before and after; the job and duties as a line diff. */
746export function ChangeList({ rows }: { rows: ChangeRow[] }) {
747 return (
748 <dl className="mt-3 divide-y divide-line overflow-hidden rounded-lg border border-line bg-bg">
749 {rows.map((row) => (
750 <div key={row.field} className="px-3 py-2.5">
751 <dt className="text-xs font-medium text-faint">{row.label}</dt>
752 {row.lines ? (
753 <dd className="mt-1.5 max-h-80 overflow-auto rounded-md border border-line font-mono text-[0.75rem] leading-relaxed">
754 {row.lines.map((line, at) => (
755 <div
756 key={at}
757 className={cn(
758 "flex gap-2 px-2 whitespace-pre-wrap",
759 line.kind === "added" && "bg-success/10 text-success",
760 line.kind === "removed" && "bg-danger/10 text-danger line-through decoration-danger/40",
761 line.kind === "same" && "text-muted",
762 )}
763 >
764 <span aria-hidden="true" className="w-3 shrink-0 select-none">
765 {line.kind === "added" ? "+" : line.kind === "removed" ? "−" : " "}
766 </span>
767 <span className="sr-only">{line.kind === "added" ? "Added: " : line.kind === "removed" ? "Removed: " : ""}</span>
768 <span className="min-w-0">{line.text || " "}</span>
769 </div>
770 ))}
771 </dd>
772 ) : (
773 <dd className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
774 <span className="text-muted line-through decoration-line-strong">{row.before}</span>
775 <span aria-hidden="true" className="text-faint">
776 →
777 </span>
778 <span className="sr-only">becomes</span>
779 <span className="font-medium text-fg">{row.after}</span>
780 </dd>
781 )}
782 </div>
783 ))}
784 </dl>
785 );
786}
787
788/** Whose personal agent it is, and, for owners, making it the workspace's. */
789export function PersonalNotice({ agent, owner, mine }: { agent: WorkspaceAgent; owner: boolean; mine: boolean }) {
790 const navigation = useNavigation();
791 const [asking, setAsking] = useState(false);
792 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "promote";
793 return (
794 <section aria-labelledby="personal" className="mb-10 rounded-2xl border border-line bg-surface p-4 sm:p-5">
795 <h2 id="personal" className="flex items-center gap-2 text-sm font-semibold">
796 <Lock size={14} className="text-faint" />
797 {mine ? "Your personal agent" : `@${agent.personal_owner ?? "a member"}'s personal agent`}
798 </h2>
799 <p className="mt-1 text-sm text-muted">
800 {mine
801 ? "Only you can talk to it, in your direct message with it, and it spends from your budget. It isn't in channels, and other agents don't hand it work."
802 : `Only @${agent.personal_owner ?? "its member"} talks to it, and it spends from their budget. As an owner you can archive it, or make it a workspace agent everyone can talk to.`}
803 </p>
804 {owner && (
805 <div className="mt-4">
806 {asking ? (
807 <Form method="post" className="space-y-3">
808 <input type="hidden" name="intent" value="promote" />
809 <p className="text-sm text-fg-soft">
810 {agent.display_name} becomes a workspace agent with the same handle, job and every version so far. What it remembers from{" "}
811 {mine ? "your" : `@${agent.personal_owner}'s`} direct messages stays behind with the personal agent, which is archived.
812 </p>
813 <div className="flex flex-wrap gap-2">
814 <button type="submit" disabled={busy} className={PRIMARY}>
815 <Users size={14} />
816 {busy ? "Promoting…" : `Promote ${agent.display_name}`}
817 </button>
818 <button type="button" onClick={() => setAsking(false)} className={QUIET}>
819 Cancel
820 </button>
821 </div>
822 </Form>
823 ) : (
824 <button type="button" onClick={() => setAsking(true)} className={QUIET}>
825 <Users size={14} />
826 Promote to a workspace agent…
827 </button>
828 )}
829 </div>
830 )}
831 </section>
832 );
833}