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