Skip to content
197 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 { Button } from "../ui/button";
17import { Card } from "../ui/card";
18import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
19import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
20import { Input, InputAddon, InputGroup } from "../ui/input";
21import { dollarsField } from "../../lib/agent-form";
22import { money } from "../../lib/money";
23import { PrivateTitle, SpendOfCap } from "./parts";
24
25/** What Agents mode's actions answer. */
26export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string };
27
28/** A fetcher whose dialog closes once what it sent has worked. */
29export function useDialogFetcher(key?: string) {
30 const fetcher = useFetcher<ActionResult>(key ? { key } : undefined);
31 const [open, setOpen] = useState(false);
32 useEffect(() => {
33 if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
34 }, [fetcher.state, fetcher.data]);
35 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
36 return { fetcher, open, setOpen, error, busy: fetcher.state !== "idle" };
37}
38
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" variant="outline" onClick={() => setOpen(false)}>
87 Cancel
88 </Button>
89 <Button type="submit" variant="accent" 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 <Card asChild tone="plain" radius="lg" className="bg-bg/50 px-3 py-2 text-sm text-muted">
121 <p>
122 {session.agent_name} has spent <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} /> on it so far.
123 </p>
124 </Card>
125 <fetcher.Form method="post" action={`/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`} className="grid gap-5">
126 <input type="hidden" name="intent" value="approve" />
127 <Field>
128 <FieldLabel htmlFor={`${id}-cap`}>New cap</FieldLabel>
129 <DollarsInput id={`${id}-cap`} name="cap" defaultValue={dollarsField(suggestedCap(session))} required />
130 <FieldDescription>More than the {money(session.charged_micros)} it has spent. Charged to {session.agent_name}&apos;s budget.</FieldDescription>
131 </Field>
132 <FieldError>{error}</FieldError>
133 <DialogFooter>
134 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
135 Cancel
136 </Button>
137 <Button type="submit" variant="accent" disabled={busy}>
138 {busy ? "Approving…" : "Approve and go on"}
139 </Button>
140 </DialogFooter>
141 </fetcher.Form>
142 </DialogContent>
143 </Dialog>
144 );
145}
146
147/**
148 * A choice that is asked twice: the trigger opens a question, and only its
149 * confirm button posts `fields` to `action`.
150 */
151export function Confirm({
152 trigger,
153 title,
154 children,
155 confirm,
156 action,
157 fields,
158 fetcherKey,
159}: {
160 trigger: ReactNode;
161 title: string;
162 children: ReactNode;
163 confirm: string;
164 action?: string;
165 fields: Record<string, string>;
166 fetcherKey?: string;
167}) {
168 const { fetcher, open, setOpen, error, busy } = useDialogFetcher(fetcherKey);
169 return (
170 <AlertDialog open={open} onOpenChange={setOpen}>
171 <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
172 <AlertDialogContent>
173 <AlertDialogHeader>
174 <AlertDialogTitle>{title}</AlertDialogTitle>
175 <AlertDialogDescription asChild>
176 <div>{children}</div>
177 </AlertDialogDescription>
178 </AlertDialogHeader>
179 {error && <p className="text-sm text-danger">{error}</p>}
180 <fetcher.Form method="post" action={action}>
181 {Object.entries(fields).map(([name, value]) => (
182 <input key={name} type="hidden" name={name} value={value} />
183 ))}
184 <AlertDialogFooter>
185 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
186 <Button
187 type="submit"
188 disabled={busy} className="bg-danger hover:bg-danger/90">
189 {busy ? "Working…" : confirm}
190 </Button>
191 </AlertDialogFooter>
192 </fetcher.Form>
193 </AlertDialogContent>
194 </AlertDialog>
195 );
196}
197