Skip to content
384 linesCodeBlameRaw
1import { ArrowRight } from "lucide-react";
2import { type ReactNode, Suspense } from "react";
3import { Await, Link, data } from "react-router";
4
5import type { AgentPolicy, AgentSpendBreakdown, SpendSlice, UsageReport } from "@g1t/contracts";
6
7import type { Route } from "./+types/spend";
8import { answer, agentsAction } from "../../components/agents/actions.server";
9import { RowsSkeleton } from "../../components/today";
10import {
11 BudgetLadder,
12 ComingNote,
13 PricingCard,
14 Section,
15 SliceList,
16 SlicePanel,
17 type SliceKey,
18 SLICES,
19 SliceTabs,
20 SpendBars,
21 TaskList,
22 Tile,
23 TileSkeleton,
24} from "../../components/spend";
25import { Meter } from "../../components/agents/parts";
26import { Savings } from "../../components/effort";
27import { readOrNull } from "../../components/agents/actions.server";
28import { Skeleton } from "../../components/ui/skeleton";
29import { microsFromDollars } from "../../lib/agent-form";
30import { cn } from "../../lib/cn";
31import { page } from "../../lib/meta";
32import { workspaceAgents } from "../../lib/services.server";
33import { managesBilling, requireUser, roleIn } from "../../lib/session.server";
34import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend";
35import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server";
36import { money } from "../../lib/usage";
37
38export function meta({ params, ...args }: Route.MetaArgs) {
39 return page(args, { title: `Spend · ${params.owner} · g1t` });
40}
41
42/**
43 * Spend: the front of the workspace's money. What was spent over a
44 * period, where it went (by agent, person, channel, model, kind of work and
45 * product), the budgets from the workspace down to one task with what
46 * happens at each, the costliest tasks with their receipts, and how it is
47 * priced. Owners and billing managers see the workspace's, or their own;
48 * everyone else sees their own: what agents did for them. Each part
49 * streams in from its own service. Definitions: lib/spend.ts.
50 */
51export async function loader({ params, context, request }: Route.LoaderArgs) {
52 const viewer = requireUser(context, request);
53 const slug = params.owner.toLowerCase();
54 const role = roleIn(viewer, slug);
55 if (!role) throw data(null, { status: 404 });
56 const url = new URL(request.url);
57 const mayWorkspace = managesBilling(viewer, slug);
58 const scope = readScope(url.searchParams.get("scope"), mayWorkspace);
59 const period = readPeriod(url.searchParams.get("period"));
60 const by = readSlice(url.searchParams.get("by"), scope);
61 const now = new Date();
62
63 const breakdown = loadBreakdown(viewer, slug, scope, period);
64 // The budgets are monthly: every agent's month, read once when the page is on it already.
65 const month = scope === "workspace" && period === "month" ? breakdown : loadBreakdown(viewer, slug, "workspace", "month");
66 const usage = scope === "workspace" ? loadUsage(viewer, slug, period, now) : Promise.resolve(null);
67 return {
68 slug,
69 me: viewer.username.toLowerCase(),
70 owner: role === "owner",
71 mayWorkspace,
72 scope,
73 period,
74 by,
75 span: spanFor(period, now),
76 breakdown,
77 usage,
78 budgets: loadBudgets(viewer, slug, month),
79 pricing: loadPricing(),
80 // Spend less, keep quality: the weekly check's suggestions.
81 recs: readOrNull(workspaceAgents.recommendations(slug, viewer)),
82 };
83}
84
85function readSlice(raw: string | null, scope: SpendScope): SliceKey {
86 const found = SLICES.find((s) => s.key === raw && (scope === "workspace" || !s.workspaceOnly));
87 return found?.key ?? "agent";
88}
89
90/** Owners set the agent budgets: every agent together, each person, a new agent, a session; and one person's own. */
91export async function action({ params, context, request }: Route.ActionArgs) {
92 const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner);
93 const intent = form.get("intent");
94 if (intent === "recommendation") {
95 if (!isOwner) return { ok: false as const, intent, error: "Only the workspace's owners apply suggestions." };
96 const what = form.get("do") === "apply" ? "apply" : "dismiss";
97 return answer(intent, workspaceAgents.resolveRecommendation(slug, viewer, String(form.get("id") ?? ""), what));
98 }
99 if (!isOwner) return { ok: false as const, error: "Only the workspace's owners set budgets." };
100 if (intent === "policy") {
101 const monthly = microsFromDollars(form.get("monthly"));
102 const person = microsFromDollars(form.get("person"));
103 const agent = microsFromDollars(form.get("default_agent"));
104 const session = microsFromDollars(form.get("default_session"));
105 if ([monthly, person, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, intent, error: "Write each amount in dollars, such as 250 or 2.50." };
106 const changes: Partial<AgentPolicy> = { monthly_micros: monthly, person_monthly_micros: person, default_agent_monthly_micros: agent };
107 if (session != null) changes.default_session_micros = session;
108 return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes));
109 }
110 if (intent === "person") {
111 const username = String(form.get("username") ?? "").trim();
112 const amount = microsFromDollars(form.get("amount"));
113 if (amount != null && Number.isNaN(amount)) return { ok: false as const, intent, error: "Write the amount in dollars, such as 50, or 0 for no budget." };
114 return answer("person", workspaceAgents.setPersonBudget(slug, viewer, username, amount));
115 }
116 return { ok: false as const, error: "Unknown request." };
117}
118
119/** The page's address with some of its query changed. */
120function hrefFor(slug: string, q: { scope: SpendScope; period: string; by: SliceKey }, change: Partial<{ scope: SpendScope; period: string; by: SliceKey }>): string {
121 const next = { ...q, ...change };
122 const params = new URLSearchParams();
123 if (next.scope === "me") params.set("scope", "me");
124 if (next.period !== "month") params.set("period", next.period);
125 if (next.by !== "agent") params.set("by", next.by);
126 const query = params.toString();
127 return `/${slug}/-/spend${query ? `?${query}` : ""}`;
128}
129
130export default function SpendPage({ loaderData }: Route.ComponentProps) {
131 const { slug, me, owner, mayWorkspace, scope, period, by, span, breakdown, usage, budgets, pricing, recs } = loaderData;
132 const q = { scope, period, by };
133 const href = (change: Partial<typeof q>) => hrefFor(slug, q, change);
134 const label = periodLabel(period).toLowerCase();
135 return (
136 <div className="mx-auto w-full max-w-[1040px] space-y-8 px-4 py-5 md:px-10 md:py-8">
137 <header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
138 <div className="min-w-0">
139 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Spend</h1>
140 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
141 {scope === "workspace"
142 ? "What the workspace spent, where it went, and the budgets that hold it, from the workspace down to one task."
143 : "What agents spent working for you: your chats with them and the sessions you started, against your budget."}
144 </p>
145 </div>
146 <div className="flex flex-wrap items-center gap-2">
147 {mayWorkspace && (
148 <div role="group" aria-label="Whose spend" className="inline-flex rounded-lg border border-line bg-surface p-0.5">
149 {(["workspace", "me"] as const).map((key) => (
150 <Link
151 key={key}
152 to={href({ scope: key, by: "agent" })}
153 aria-current={scope === key ? "page" : undefined}
154 className={cn("inline-flex h-7 items-center rounded-md px-3 text-[0.8125rem] transition-colors", scope === key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")}
155 >
156 {key === "workspace" ? "Workspace" : "You"}
157 </Link>
158 ))}
159 </div>
160 )}
161 <nav aria-label="Period" className="inline-flex rounded-lg border border-line bg-surface p-0.5">
162 {SPEND_PERIODS.map((p) => (
163 <Link
164 key={p.key}
165 to={href({ period: p.key })}
166 preventScrollReset
167 aria-current={period === p.key ? "page" : undefined}
168 className={cn(
169 "inline-flex h-7 items-center rounded-md px-2.5 text-[0.8125rem] whitespace-nowrap transition-colors",
170 period === p.key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg",
171 )}
172 >
173 {p.short}
174 </Link>
175 ))}
176 </nav>
177 </div>
178 </header>
179
180 {/* The figures at the top. */}
181 <Suspense
182 fallback={
183 <div className="grid gap-3 sm:grid-cols-3">
184 <TileSkeleton />
185 <TileSkeleton />
186 <TileSkeleton />
187 </div>
188 }
189 >
190 <Await resolve={Promise.all([breakdown, usage, budgets])}>
191 {([spent, used, levels]) => (
192 <div className="grid gap-3 sm:grid-cols-3">
193 {scope === "workspace" ? (
194 <>
195 <Tile label={`Spent, ${label}`} value={used ? money(used.free ? used.totals.costMicros : used.totals.priceMicros) : "—"} sub={used ? (used.free ? "At cost: g1t charges nothing for now" : "Usage at price, every product") : "Usage couldn't be read"} />
196 <Tile label={`Agents, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions` : "The agents service didn't answer"} />
197 <Tile
198 label="Charged, this month"
199 value={levels.limit?.spentMicros != null ? money(levels.limit.spentMicros) : "—"}
200 sub={levels.limit ? limitLine(levels.limit.spentMicros ?? 0, levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) : "Billing couldn't be read"}
201 >
202 {levels.limit && (levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) != null && (
203 <Meter spent={levels.limit.spentMicros ?? 0} cap={levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros} label="Charged of the spend limit" size="sm" className="mt-2.5" />
204 )}
205 </Tile>
206 </>
207 ) : (
208 <MyTiles spent={spent} levels={levels} me={me} label={label} />
209 )}
210 </div>
211 )}
212 </Await>
213 </Suspense>
214
215 {/* Spend by day. */}
216 <Section id="by-day" title="By day" aside={`${periodLabel(period)}, UTC`}>
217 <div className="rounded-xl border border-line bg-surface px-4 pt-4 pb-3">
218 <Suspense fallback={<Skeleton className="h-32 w-full" />}>
219 <Await resolve={Promise.all([breakdown, usage])}>
220 {([spent, used]) => {
221 const days = scope === "workspace" && used ? usageByDay(used.days) : (spent?.days ?? null);
222 return days ? <SpendBars days={daySeries(span.from, span.until, days)} /> : <p className="py-8 text-center text-sm text-muted">Spend by day couldn&apos;t be read right now.</p>;
223 }}
224 </Await>
225 </Suspense>
226 </div>
227 </Section>
228
229 {/* Where it went. */}
230 <Section id="where" title="Where it went" aside={scope === "workspace" ? "Agent work by who and where; every product from usage" : "Agent work you asked for"}>
231 <SliceTabs current={by} scope={scope} href={(key) => href({ by: key })} />
232 <Suspense
233 fallback={
234 <SlicePanel>
235 <RowsSkeleton />
236 </SlicePanel>
237 }
238 >
239 <Await resolve={Promise.all([breakdown, usage])}>{([spent, used]) => <Slices by={by} spent={spent} used={used} slug={slug} me={me} scope={scope} />}</Await>
240 </Suspense>
241 </Section>
242
243 {/* Spend less, keep quality. */}
244 <Section
245 id="savings"
246 title="Spend less, keep quality"
247 aside={
248 <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1 hover:text-fg">
249 Set an agent&apos;s effort on its Spend tab <ArrowRight size={11} />
250 </Link>
251 }
252 >
253 <Suspense fallback={<Skeleton className="h-28 w-full rounded-xl" />}>
254 <Await resolve={recs}>{(value) => <Savings recs={value} owner={owner} action={`/${slug}/-/spend`} />}</Await>
255 </Suspense>
256 </Section>
257
258 {/* Budgets. */}
259 <Section id="budgets" title="Budgets" aside="Monthly, UTC. Checked before work starts, widest first">
260 <Suspense
261 fallback={
262 <SlicePanel>
263 <RowsSkeleton rows={5} />
264 </SlicePanel>
265 }
266 >
267 <Await resolve={budgets}>{(levels) => <BudgetLadder slug={slug} budgets={levels} owner={owner} me={me} action={`/${slug}/-/spend`} workspaceFigures={mayWorkspace} />}</Await>
268 </Suspense>
269 </Section>
270
271 {/* Receipts. */}
272 <Section id="tasks" title="Costliest tasks" aside="Open one for its receipt">
273 <SlicePanel>
274 <Suspense fallback={<RowsSkeleton />}>
275 <Await resolve={breakdown}>{(spent) => (spent ? <TaskList slug={slug} sessions={spent.top_sessions} /> : <p className="px-4 py-6 text-center text-sm text-muted">Tasks couldn&apos;t be read right now.</p>)}</Await>
276 </Suspense>
277 </SlicePanel>
278 </Section>
279
280 {/* Pricing. */}
281 <Section
282 id="pricing"
283 title="How it's priced"
284 aside={
285 <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1 hover:text-fg">
286 Every meter on Usage <ArrowRight size={11} />
287 </Link>
288 }
289 >
290 <Suspense fallback={<Skeleton className="h-36 w-full rounded-xl" />}>
291 <Await resolve={pricing}>{(value) => <PricingCard pricing={value} />}</Await>
292 </Suspense>
293 </Section>
294
295 <p className="text-xs text-faint">
296 Agent figures count every reply and session at list price, as budgets do. What the workspace is charged, after included usage, credit and any
297 discount, is on{" "}
298 <Link to={`/${slug}/-/billing`} className="hover:text-fg hover:underline">
299 Billing
300 </Link>
301 .
302 </p>
303 </div>
304 );
305}
306
307function countOf(slices: SpendSlice[]): string {
308 return slices.reduce((n, s) => n + s.count, 0).toLocaleString("en-US");
309}
310
311function limitLine(spent: number, limit: number | null): string {
312 if (limit == null) return "No spend limit set";
313 return `Of a ${money(limit)} spend limit · ${Math.round((spent / Math.max(1, limit)) * 100)}%`;
314}
315
316/** Your own figures: what agents spent for you, and your budget. */
317function MyTiles({ spent, levels, me, label }: { spent: AgentSpendBreakdown | null; levels: Awaited<Route.ComponentProps["loaderData"]["budgets"]>; me: string; label: string }) {
318 const people = levels.people;
319 const mine = people?.people.find((p) => p.username === me) ?? null;
320 const budget = mine ? mine.monthly_micros : (people?.default_micros ?? null);
321 const month = mine?.spent_micros ?? null;
322 const replies = spent?.by_kind.find((s) => s.key === "reply");
323 return (
324 <>
325 <Tile label={`Agents for you, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions you asked for` : "The agents service didn't answer"} />
326 <Tile label="Your budget, this month" value={budget != null ? money(budget) : "None"} sub={budget != null ? `${month != null ? money(month) : "—"} spent · ${mine?.own ? "set for you" : "the workspace's default"}` : "The workspace's limits apply"}>
327 {budget != null && month != null && <Meter spent={month} cap={budget} label="Spent of your budget" size="sm" className="mt-2.5" />}
328 </Tile>
329 <Tile label={`Your chats with agents, ${label}`} value={replies ? money(replies.micros) : spent ? money(0) : "—"} sub={replies ? `${replies.count.toLocaleString("en-US")} replies` : "Talking costs only what the model answers"} />
330 </>
331 );
332}
333
334/** The breakdown for the slice chosen. */
335function Slices({ by, spent, used, slug, me, scope }: { by: SliceKey; spent: AgentSpendBreakdown | null; used: UsageReport | null; slug: string; me: string; scope: SpendScope }) {
336 if (by === "extension") {
337 return (
338 <SlicePanel>
339 <ComingNote title="Spend by extension">Once extensions can be installed, what each one runs and what agents do in it will show here, priced like everything else.</ComingNote>
340 </SlicePanel>
341 );
342 }
343 if (by === "product") {
344 if (!used) return <Unavailable what="Usage" />;
345 const slices: SpendSlice[] = used.products.map((p) => ({ key: p.key, label: p.label, micros: p.micros, count: 0 }));
346 return (
347 <SlicePanel foot="Every product, at price, from Usage. Code and Deployments are built in.">
348 <SliceList slices={slices} empty="Nothing used in this period." href={() => `/${slug}/-/usage`} />
349 </SlicePanel>
350 );
351 }
352 if (!spent) return <Unavailable what="The agents service" />;
353 const pick: Record<Exclude<SliceKey, "extension" | "product">, { slices: SpendSlice[]; unit: string; foot?: ReactNode; link?: (s: SpendSlice) => string | null }> = {
354 agent: {
355 slices: spent.by_agent,
356 unit: "runs",
357 foot: "A session's helpers and subagents count on the agent that started it.",
358 link: (s) => {
359 const handle = /\(@([^)]+)\)$/.exec(s.label)?.[1];
360 return handle ? `/${slug}/-/agents/${handle}/spend` : null;
361 },
362 },
363 person: { slices: spent.by_person, unit: "runs", foot: "Who asked. Routines and agent-to-agent work have no asker." },
364 channel: { slices: spent.by_channel, unit: "runs", foot: "Where it was asked. Channels you aren't in show only what they cost." },
365 model: { slices: spent.by_model, unit: "runs" },
366 kind: { slices: spent.by_kind, unit: "" },
367 };
368 const chosen = pick[by];
369 const slices = scope === "me" && by === "person" ? withoutSelf(chosen.slices, me) : chosen.slices;
370 return (
371 <SlicePanel foot={chosen.foot}>
372 <SliceList slices={slices} unit={chosen.unit} empty="Nothing spent in this period." href={chosen.link} />
373 </SlicePanel>
374 );
375}
376
377function Unavailable({ what }: { what: string }) {
378 return (
379 <SlicePanel>
380 <p className="px-4 py-6 text-center text-sm text-muted">{what} didn&apos;t answer. Reload in a moment.</p>
381 </SlicePanel>
382 );
383}
384