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