Skip to content
198 linesCodeBlameRaw
1import { type ReactNode, useEffect, useId, useState } from "react";
2import { useFetcher } from "react-router";
3
4import type { AgentPolicy, AgentSession } from "@g1t/contracts";
5
6import {
7 AlertDialog,
8 AlertDialogCancel,
9 AlertDialogContent,
10 AlertDialogDescription,
11 AlertDialogFooter,
12 AlertDialogHeader,
13 AlertDialogTitle,
14 AlertDialogTrigger,
15} from "../ui/alert-dialog";
16import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
17import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
18import { Input, InputAddon, InputGroup } from "../ui/input";
19import { dollarsField } from "../../lib/agent-form";
20import { money } from "../../lib/usage";
21import { PrivateTitle, SpendOfCap } from "./parts";
22
23/** What Agents mode's actions answer. */
24export 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. */
27export 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
37const 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";
38const 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. */
41export 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 */
54export 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&apos;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&apos;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&apos;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&apos;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. */
100export 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. */
107export 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 ? <>&ldquo;{session.title}&rdquo;</> : <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}&apos;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 */
149export 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
198export const BUTTONS = { PRIMARY, QUIET };