| 1 | import { type ReactNode, useEffect, useId, useState } from "react"; |
| 2 | import { useFetcher } from "react-router"; |
| 3 | |
| 4 | import type { AgentPolicy, AgentSession } from "@g1t/contracts"; |
| 5 | |
| 6 | import { |
| 7 | AlertDialog, |
| 8 | AlertDialogCancel, |
| 9 | AlertDialogContent, |
| 10 | AlertDialogDescription, |
| 11 | AlertDialogFooter, |
| 12 | AlertDialogHeader, |
| 13 | AlertDialogTitle, |
| 14 | AlertDialogTrigger, |
| 15 | } from "../ui/alert-dialog"; |
| 16 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; |
| 17 | import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field"; |
| 18 | import { Input, InputAddon, InputGroup } from "../ui/input"; |
| 19 | import { dollarsField } from "../../lib/agent-form"; |
| 20 | import { money } from "../../lib/usage"; |
| 21 | import { PrivateTitle, SpendOfCap } from "./parts"; |
| 22 | |
| 23 | /** What Agents mode's actions answer. */ |
| 24 | export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string }; |
| 25 | |
| 26 | /** A fetcher whose dialog closes once what it sent has worked. */ |
| 27 | export function useDialogFetcher(key?: string) { |
| 28 | const fetcher = useFetcher<ActionResult>(key ? { key } : undefined); |
| 29 | const [open, setOpen] = useState(false); |
| 30 | useEffect(() => { |
| 31 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); |
| 32 | }, [fetcher.state, fetcher.data]); |
| 33 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 34 | return { fetcher, open, setOpen, error, busy: fetcher.state !== "idle" }; |
| 35 | } |
| 36 | |
| 37 | const PRIMARY = "inline-flex items-center justify-center gap-2 rounded-md bg-accent px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"; |
| 38 | const QUIET = "inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"; |
| 39 | |
| 40 | /** A dollars field: `$` before it, posted as dollars. */ |
| 41 | export function DollarsInput({ id, name, defaultValue, placeholder, required }: { id: string; name: string; defaultValue: string; placeholder?: string; required?: boolean }) { |
| 42 | return ( |
| 43 | <InputGroup> |
| 44 | <InputAddon>$</InputAddon> |
| 45 | <Input id={id} name={name} inputMode="decimal" defaultValue={defaultValue} placeholder={placeholder} required={required} autoComplete="off" /> |
| 46 | </InputGroup> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * The workspace's agent budget, for owners: one monthly budget across every |
| 52 | * agent, the budget a new agent starts with, and the cap a session starts with. |
| 53 | */ |
| 54 | export function BudgetDialog({ policy, action, trigger }: { policy: AgentPolicy; action: string; trigger: ReactNode }) { |
| 55 | const { fetcher, open, setOpen, error, busy } = useDialogFetcher("agents-budget"); |
| 56 | const id = useId(); |
| 57 | return ( |
| 58 | <Dialog open={open} onOpenChange={setOpen}> |
| 59 | <DialogTrigger asChild>{trigger}</DialogTrigger> |
| 60 | <DialogContent> |
| 61 | <DialogHeader> |
| 62 | <DialogTitle>Agent budget</DialogTitle> |
| 63 | <DialogDescription> |
| 64 | What every agent may spend together each month, and what new agents and sessions start with. The workspace's spend limit still sits above all of it. |
| 65 | </DialogDescription> |
| 66 | </DialogHeader> |
| 67 | <fetcher.Form method="post" action={action} className="grid gap-5"> |
| 68 | <input type="hidden" name="intent" value="policy" /> |
| 69 | <Field> |
| 70 | <FieldLabel htmlFor={`${id}-monthly`}>Monthly budget for all agents</FieldLabel> |
| 71 | <DollarsInput id={`${id}-monthly`} name="monthly" defaultValue={dollarsField(policy.monthly_micros)} placeholder="No budget of its own" /> |
| 72 | <FieldDescription>Every agent's replies and sessions together, reset on the 1st (UTC). The Agents page warns at 75% and 90%; at 100% agents take no new work. Empty: only the workspace's spend limit.</FieldDescription> |
| 73 | </Field> |
| 74 | <Field> |
| 75 | <FieldLabel htmlFor={`${id}-agent`}>Monthly budget for a new agent</FieldLabel> |
| 76 | <DollarsInput id={`${id}-agent`} name="default_agent" defaultValue={dollarsField(policy.default_agent_monthly_micros)} placeholder="None" /> |
| 77 | <FieldDescription>Filled in when an agent is hired; change each one on its profile.</FieldDescription> |
| 78 | </Field> |
| 79 | <Field> |
| 80 | <FieldLabel htmlFor={`${id}-session`}>Cap for a session</FieldLabel> |
| 81 | <DollarsInput id={`${id}-session`} name="default_session" defaultValue={dollarsField(policy.default_session_micros)} required /> |
| 82 | <FieldDescription>A session stops here and asks an owner before spending more, unless its agent's per-task cap is lower.</FieldDescription> |
| 83 | </Field> |
| 84 | <FieldError>{error}</FieldError> |
| 85 | <DialogFooter> |
| 86 | <button type="button" className={QUIET} onClick={() => setOpen(false)}> |
| 87 | Cancel |
| 88 | </button> |
| 89 | <button type="submit" className={PRIMARY} disabled={busy}> |
| 90 | {busy ? "Saving…" : "Save budget"} |
| 91 | </button> |
| 92 | </DialogFooter> |
| 93 | </fetcher.Form> |
| 94 | </DialogContent> |
| 95 | </Dialog> |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | /** A suggested new cap: double the old one, and always above what it has spent. */ |
| 100 | export function suggestedCap(session: Pick<AgentSession, "charged_micros" | "cap_micros">): number { |
| 101 | const base = Math.max(session.cap_micros ?? 0, session.charged_micros); |
| 102 | const doubled = Math.max(base * 2, session.charged_micros + 1_000_000); |
| 103 | return Math.ceil(doubled / 10_000) * 10_000; |
| 104 | } |
| 105 | |
| 106 | /** Owners let a session that stopped at its cap go on, with a new cap. */ |
| 107 | export function ApproveDialog({ slug, session, trigger }: { slug: string; session: AgentSession; trigger: ReactNode }) { |
| 108 | const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`approve-${session.id}`); |
| 109 | const id = useId(); |
| 110 | return ( |
| 111 | <Dialog open={open} onOpenChange={setOpen}> |
| 112 | <DialogTrigger asChild>{trigger}</DialogTrigger> |
| 113 | <DialogContent> |
| 114 | <DialogHeader> |
| 115 | <DialogTitle>Approve more spend</DialogTitle> |
| 116 | <DialogDescription> |
| 117 | {session.visible ? <>“{session.title}”</> : <PrivateTitle />} stopped at its cap. A new cap lets it go on; it stops again there. |
| 118 | </DialogDescription> |
| 119 | </DialogHeader> |
| 120 | <p className="rounded-lg border border-line bg-bg/50 px-3 py-2 text-sm text-muted"> |
| 121 | {session.agent_name} has spent <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} /> on it so far. |
| 122 | </p> |
| 123 | <fetcher.Form method="post" action={`/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`} className="grid gap-5"> |
| 124 | <input type="hidden" name="intent" value="approve" /> |
| 125 | <Field> |
| 126 | <FieldLabel htmlFor={`${id}-cap`}>New cap</FieldLabel> |
| 127 | <DollarsInput id={`${id}-cap`} name="cap" defaultValue={dollarsField(suggestedCap(session))} required /> |
| 128 | <FieldDescription>More than the {money(session.charged_micros)} it has spent. Charged to {session.agent_name}'s budget.</FieldDescription> |
| 129 | </Field> |
| 130 | <FieldError>{error}</FieldError> |
| 131 | <DialogFooter> |
| 132 | <button type="button" className={QUIET} onClick={() => setOpen(false)}> |
| 133 | Cancel |
| 134 | </button> |
| 135 | <button type="submit" className={PRIMARY} disabled={busy}> |
| 136 | {busy ? "Approving…" : "Approve and go on"} |
| 137 | </button> |
| 138 | </DialogFooter> |
| 139 | </fetcher.Form> |
| 140 | </DialogContent> |
| 141 | </Dialog> |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * A choice that is asked twice: the trigger opens a question, and only its |
| 147 | * confirm button posts `fields` to `action`. |
| 148 | */ |
| 149 | export function Confirm({ |
| 150 | trigger, |
| 151 | title, |
| 152 | children, |
| 153 | confirm, |
| 154 | action, |
| 155 | fields, |
| 156 | fetcherKey, |
| 157 | }: { |
| 158 | trigger: ReactNode; |
| 159 | title: string; |
| 160 | children: ReactNode; |
| 161 | confirm: string; |
| 162 | action?: string; |
| 163 | fields: Record<string, string>; |
| 164 | fetcherKey?: string; |
| 165 | }) { |
| 166 | const { fetcher, open, setOpen, error, busy } = useDialogFetcher(fetcherKey); |
| 167 | return ( |
| 168 | <AlertDialog open={open} onOpenChange={setOpen}> |
| 169 | <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger> |
| 170 | <AlertDialogContent> |
| 171 | <AlertDialogHeader> |
| 172 | <AlertDialogTitle>{title}</AlertDialogTitle> |
| 173 | <AlertDialogDescription asChild> |
| 174 | <div>{children}</div> |
| 175 | </AlertDialogDescription> |
| 176 | </AlertDialogHeader> |
| 177 | {error && <p className="text-sm text-danger">{error}</p>} |
| 178 | <fetcher.Form method="post" action={action}> |
| 179 | {Object.entries(fields).map(([name, value]) => ( |
| 180 | <input key={name} type="hidden" name={name} value={value} /> |
| 181 | ))} |
| 182 | <AlertDialogFooter> |
| 183 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> |
| 184 | <button |
| 185 | type="submit" |
| 186 | disabled={busy} |
| 187 | className="inline-flex items-center justify-center gap-2 rounded-md bg-danger px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-danger/90 disabled:opacity-50" |
| 188 | > |
| 189 | {busy ? "Working…" : confirm} |
| 190 | </button> |
| 191 | </AlertDialogFooter> |
| 192 | </fetcher.Form> |
| 193 | </AlertDialogContent> |
| 194 | </AlertDialog> |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | export const BUTTONS = { PRIMARY, QUIET }; |