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