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