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