Skip to content
735 linesCodeBlameRaw
1/**
2 * Spend's parts: the tiles at the top, spend by day, where it went, the
3 * budgets from the workspace down to a task with what happens at each,
4 * the costliest tasks with their receipts, pricing, and the top bar's
5 * spend pill. Every figure is one a service returned (lib/spend.server.ts).
6 */
7import { ArrowRight, Building2, Coins, Cpu, KeyRound, MessagesSquare, Puzzle, ReceiptText, Sparkles, UserRound, Users } from "lucide-react";
8import { type ReactNode, useEffect, useId, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import type { AgentPolicy, AgentSession, PersonBudget } from "@g1t/contracts";
12
13import { AgentAvatar } from "./agent-avatar";
14import { DollarsInput, useDialogFetcher } from "./agents/dialogs";
15import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts";
16import { monthName, shortDay } from "./agents/format";
17import { Badge } from "./ui/badge";
18import { Button } from "./ui/button";
19import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
20import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
21import { Hint } from "./ui/hint";
22import { Input } from "./ui/input";
23import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
24import { Skeleton } from "./ui/skeleton";
25import { ButtonLink, TimeAgo } from "./ui";
26import { Card } from "./ui/card";
27import { dollarsField } from "../lib/agent-form";
28import { cn } from "../lib/cn";
29import type { AgentBudgetRow, Budgets, PillData } from "../lib/spend.server";
30import { AT_LIMIT, type Pricing, type SpendScope, agentRateLabel, markupLabel, percentLabel, shareOfBudget } from "../lib/spend";
31import { money } from "../lib/usage";
32
33
34/** A figure at the top of the page: what it is, the amount, and a line under it. */
35export function Tile({ label, value, sub, children }: { label: ReactNode; value: ReactNode; sub?: ReactNode; children?: ReactNode }) {
36 return (
37 <Card className="min-w-0 p-4">
38 <p className="flex items-center gap-1.5 text-xs text-muted">{label}</p>
39 <p className="mt-1.5 truncate text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
40 {sub && <p className="mt-0.5 text-xs text-faint">{sub}</p>}
41 {children}
42 </Card>
43 );
44}
45
46export function TileSkeleton() {
47 return (
48 <Card className="p-4">
49 <Skeleton className="h-3 w-24" />
50 <Skeleton className="mt-3 h-7 w-28" />
51 <Skeleton className="mt-2 h-3 w-36" />
52 </Card>
53 );
54}
55
56/**
57 * Spend by day over any span, one bar a day with its amount on hover or
58 * focus, and a table for screen readers.
59 */
60export function SpendBars({ days, className }: { days: { day: string; micros: number }[]; className?: string }) {
61 const max = Math.max(0, ...days.map((d) => d.micros));
62 return (
63 <div className={className}>
64 <div className="flex h-28 items-end gap-0.5" aria-hidden="true">
65 {days.map((d) => (
66 <Hint key={d.day} label={`${shortDay(d.day)} · ${money(d.micros)}`}>
67 <span className="flex h-full min-w-0 flex-1 items-end rounded-t-[3px] hover:bg-raised/60">
68 <span
69 className={cn("block w-full rounded-t-[3px]", d.micros > 0 ? "bg-accent/80" : "bg-line/70")}
70 style={{ height: max > 0 && d.micros > 0 ? `${Math.max(4, (d.micros / max) * 100)}%` : "2px" }}
71 />
72 </span>
73 </Hint>
74 ))}
75 </div>
76 <div className="mt-1.5 flex justify-between text-[0.6875rem] text-faint" aria-hidden="true">
77 <span>{days[0] ? shortDay(days[0].day) : ""}</span>
78 <span>{days.length > 1 ? shortDay(days[days.length - 1]!.day) : ""}</span>
79 </div>
80 <table className="sr-only">
81 <caption>Spend by day</caption>
82 <tbody>
83 {days.map((d) => (
84 <tr key={d.day}>
85 <th scope="row">{shortDay(d.day)}</th>
86 <td>{money(d.micros)}</td>
87 </tr>
88 ))}
89 </tbody>
90 </table>
91 </div>
92 );
93}
94
95/** A section with a heading, an aside, and what it holds. */
96export function Section({ id, title, aside, children, className }: { id: string; title: ReactNode; aside?: ReactNode; children: ReactNode; className?: string }) {
97 return (
98 <section aria-labelledby={id} className={cn("space-y-3", className)}>
99 <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
100 <h2 id={id} className="text-base font-semibold tracking-tight">
101 {title}
102 </h2>
103 {aside && <div className="text-xs text-faint">{aside}</div>}
104 </div>
105 {children}
106 </section>
107 );
108}
109
110/** The ways to slice spend, as the page's tabs. */
111export type SliceKey = "agent" | "person" | "channel" | "model" | "kind" | "product" | "extension";
112
113export const SLICES: { key: SliceKey; label: string; icon: ReactNode; workspaceOnly?: boolean }[] = [
114 { key: "agent", label: "Agents", icon: <Sparkles size={13} /> },
115 { key: "person", label: "People", icon: <Users size={13} />, workspaceOnly: true },
116 { key: "channel", label: "Channels", icon: <MessagesSquare size={13} /> },
117 { key: "model", label: "Models", icon: <Cpu size={13} /> },
118 { key: "kind", label: "Kind of work", icon: <ReceiptText size={13} /> },
119 { key: "product", label: "Products", icon: <Building2 size={13} />, workspaceOnly: true },
120 { key: "extension", label: "Extensions", icon: <Puzzle size={13} />, workspaceOnly: true },
121];
122
123/** The tabs over the breakdown: each a link, so a slice is an address. */
124export function SliceTabs({ current, scope, href }: { current: SliceKey; scope: SpendScope; href: (key: SliceKey) => string }) {
125 const shown = SLICES.filter((s) => scope === "workspace" || !s.workspaceOnly);
126 return (
127 <nav aria-label="Slice spend by" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1 [scrollbar-width:none]">
128 {shown.map((s) => (
129 <Link
130 key={s.key}
131 to={href(s.key)}
132 preventScrollReset
133 replace
134 aria-current={s.key === current ? "page" : undefined}
135 className={cn(
136 "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[0.8125rem] transition-colors",
137 s.key === current ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:bg-raised/60 hover:text-fg",
138 )}
139 >
140 <span className="text-faint">{s.icon}</span>
141 {s.label}
142 {s.key === "extension" && <span className="ml-0.5 text-[0.625rem] tracking-wide text-faint uppercase">Coming</span>}
143 </Link>
144 ))}
145 </nav>
146 );
147}
148
149/** Spend in a panel, the slices ranked. */
150export function SlicePanel({ children, foot }: { children: ReactNode; foot?: ReactNode }) {
151 return (
152 <Card className="overflow-hidden">
153 {children}
154 {foot && <p className="border-t border-line px-4 py-2.5 text-xs text-faint">{foot}</p>}
155 </Card>
156 );
157}
158
159export { SliceList };
160
161/** Something not built yet, said plainly where it will be. */
162export function ComingNote({ title, children }: { title: string; children: ReactNode }) {
163 return (
164 <div className="px-4 py-8 text-center">
165 <p className="flex items-center justify-center gap-2 text-sm font-medium">
166 {title}
167 <Badge>Coming</Badge>
168 </p>
169 <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">{children}</p>
170 </div>
171 );
172}
173
174// ── Budgets ──────────────────────────────────────────────────────────────
175
176/** One level of budget: its name, its amount, what is spent against it, and what happens at 100%. */
177function Level({
178 icon,
179 title,
180 amount,
181 spent,
182 budget,
183 atLimit,
184 action,
185 children,
186}: {
187 icon: ReactNode;
188 title: string;
189 amount: ReactNode;
190 spent?: number | null;
191 budget?: number | null;
192 atLimit: string;
193 action?: ReactNode;
194 children?: ReactNode;
195}) {
196 const share = spent != null ? shareOfBudget(spent, budget) : null;
197 return (
198 <li className="px-4 py-4">
199 <div className="flex flex-wrap items-start gap-x-3 gap-y-2">
200 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">{icon}</span>
201 <div className="min-w-0 grow basis-56">
202 <p className="text-sm font-medium">{title}</p>
203 <p className="mt-0.5 text-xs text-muted">
204 <span className="font-medium text-fg-soft">At 100%:</span> {atLimit}
205 </p>
206 </div>
207 <div className="flex shrink-0 items-center gap-3 max-sm:w-full max-sm:justify-between max-sm:pl-10">
208 <div className="sm:text-right">
209 <p className="text-sm tabular-nums">{amount}</p>
210 {spent != null && (
211 <p className="text-xs text-faint tabular-nums">
212 {money(spent)} spent{share != null ? ` · ${percentLabel(spent, budget)}` : ""}
213 </p>
214 )}
215 </div>
216 {action}
217 </div>
218 </div>
219 {share != null && <Meter spent={spent!} cap={budget} label={`${title}: spent of its budget`} size="sm" className="mt-3 ml-10" />}
220 {children}
221 </li>
222 );
223}
224
225/**
226 * The budgets, widest first: the workspace's spend limit, every agent
227 * together, each person, each agent, and each task. Owners change the
228 * agent levels here; the spend limit is on Billing, an agent's own on its
229 * profile.
230 */
231export function BudgetLadder({ slug, budgets, owner, me, action, workspaceFigures }: { slug: string; budgets: Budgets; owner: boolean; me: string; action: string; workspaceFigures: boolean }) {
232 const { limit, caps, policy, people, agents } = budgets;
233 const limitMicros = limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null;
234 const mine = people?.people.find((p) => p.username === me) ?? null;
235 return (
236 <Card asChild divided className="overflow-hidden">
237 <ol>
238 <Level
239 icon={<Building2 size={14} />}
240 title="The workspace"
241 amount={limit ? (limitMicros != null ? `${money(limitMicros)} a month` : "No limit") : <span className="text-faint">Unavailable</span>}
242 spent={limit && workspaceFigures ? (limit.spentMicros ?? null) : null}
243 budget={limitMicros}
244 atLimit={AT_LIMIT.workspace(limit?.pauseAtLimit ?? true)}
245 action={
246 <ButtonLink to={`/${slug}/-/billing`} variant="outline" size="sm">
247 {owner ? "Change" : "Billing"}
248 </ButtonLink>
249 }
250 >
251 {limit?.defaultSpendLimit && <p className="mt-2 ml-10 text-xs text-faint">Automatic until owners set one: $200, or twice last month&apos;s spend, whichever is more.</p>}
252 </Level>
253 <Level
254 icon={<Sparkles size={14} />}
255 title="All agents together"
256 amount={policy ? (policy.monthly_micros != null ? `${money(policy.monthly_micros)} a month` : "No budget of its own") : <span className="text-faint">Unavailable</span>}
257 spent={workspaceFigures ? budgets.agentsMonthMicros : null}
258 budget={policy?.monthly_micros ?? null}
259 atLimit={AT_LIMIT.agents}
260 action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<Button type="button" variant="outline" size="sm">Change</Button>} /> : null}
261 />
262 <Level
263 icon={<UserRound size={14} />}
264 title="Each person"
265 amount={policy ? (policy.person_monthly_micros != null ? `${money(policy.person_monthly_micros)} a month each` : "No budget per person") : <span className="text-faint">Unavailable</span>}
266 spent={!owner && mine ? mine.spent_micros : null}
267 budget={!owner && mine ? mine.monthly_micros : null}
268 atLimit={AT_LIMIT.person}
269 action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<Button type="button" variant="outline" size="sm">Change</Button>} /> : null}
270 >
271 <p className="mt-2 ml-10 text-xs text-faint">What agents spend on work a person asks for: their chats with agents and the sessions they start.</p>
272 {owner && people && <PeopleBudgets people={people.people} defaultMicros={people.default_micros} action={action} />}
273 </Level>
274 <Level
275 icon={<Sparkles size={14} />}
276 title="Each agent"
277 amount={policy ? (policy.default_agent_monthly_micros != null ? `${money(policy.default_agent_monthly_micros)} a month for a new agent` : "Set on each agent") : <span className="text-faint">Unavailable</span>}
278 atLimit={AT_LIMIT.agent}
279 >
280 {agents && agents.length > 0 && <AgentBudgets slug={slug} agents={agents} />}
281 </Level>
282 <Level
283 icon={<ReceiptText size={14} />}
284 title="Each task"
285 amount={policy ? `${money(policy.default_session_micros)} a session` : <span className="text-faint">Unavailable</span>}
286 atLimit={AT_LIMIT.session}
287 action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<Button type="button" variant="outline" size="sm">Change</Button>} /> : null}
288 >
289 <dl className="mt-3 ml-10 grid grid-cols-1 gap-x-6 gap-y-2 text-xs sm:grid-cols-3">
290 <Fact label="A session" value={policy ? money(policy.default_session_micros) : "—"} hint="Unless its agent's own per-task cap is lower." />
291 <Fact label="One run of the plan" value={caps ? money(caps.runMicros) : "—"} hint="Set under Caps on agents, on Billing." />
292 <Fact label="Agents on one issue" value={caps ? money(caps.issueMicros) : "—"} hint="Every run on the issue together." />
293 </dl>
294 </Level>
295 </ol>
296 </Card>
297 );
298}
299
300function Fact({ label, value, hint }: { label: string; value: string; hint: string }) {
301 return (
302 <div>
303 <dt className="text-faint">{label}</dt>
304 <dd className="mt-0.5 text-sm tabular-nums">{value}</dd>
305 <dd className="text-faint">{hint}</dd>
306 </div>
307 );
308}
309
310/** Each agent against its own monthly budget, a link to its profile. */
311function AgentBudgets({ slug, agents }: { slug: string; agents: AgentBudgetRow[] }) {
312 const [all, setAll] = useState(false);
313 const shown = all ? agents : agents.slice(0, 5);
314 return (
315 <ul className="mt-3 ml-10 space-y-2.5">
316 {shown.map((agent) => (
317 <li key={agent.id} className="flex items-center gap-2.5 text-sm">
318 <AgentAvatar agent={{ handle: agent.handle, avatar_seed: agent.avatar_seed }} size={20} />
319 <Link to={`/${slug}/-/agents/${agent.handle}/spend`} className="min-w-0 grow truncate hover:underline">
320 {agent.display_name}
321 </Link>
322 <span className="shrink-0 text-xs text-faint tabular-nums">
323 {money(agent.spent_month_micros)}
324 {agent.budget.monthly_micros != null ? ` of ${money(agent.budget.monthly_micros)}` : " · no cap"}
325 </span>
326 <span className="hidden w-24 shrink-0 sm:block">
327 {agent.budget.monthly_micros != null && <Meter spent={agent.spent_month_micros} cap={agent.budget.monthly_micros} label={`${agent.display_name}: spent of its budget`} size="sm" />}
328 </span>
329 </li>
330 ))}
331 {agents.length > 5 && (
332 <li>
333 <Button type="button" onClick={() => setAll((v) => !v)} variant="link" size="inline" className="text-xs font-normal">
334 {all ? "Show fewer" : `Show all ${agents.length}`}
335 </Button>
336 </li>
337 )}
338 </ul>
339 );
340}
341
342/** Each person who has a budget of their own or spent this month, for owners, with the way to set one. */
343function PeopleBudgets({ people, defaultMicros, action }: { people: PersonBudget[]; defaultMicros: number | null; action: string }) {
344 return (
345 <div className="mt-3 ml-10">
346 {people.length > 0 && (
347 <ul className="space-y-2.5">
348 {people.slice(0, 12).map((person) => (
349 <li key={person.username} className="flex items-center gap-2.5 text-sm">
350 <span className="min-w-0 grow truncate">
351 @{person.username}
352 {person.own && <span className="ml-1.5 text-xs text-faint">own budget</span>}
353 </span>
354 <span className="shrink-0 text-xs text-faint tabular-nums">
355 {money(person.spent_micros)}
356 {person.monthly_micros != null ? ` of ${money(person.monthly_micros)}` : " · no budget"}
357 </span>
358 <span className="hidden w-24 shrink-0 sm:block">
359 {person.monthly_micros != null && <Meter spent={person.spent_micros} cap={person.monthly_micros} label={`@${person.username}: spent of their budget`} size="sm" />}
360 </span>
361 <PersonDialog action={action} person={person} defaultMicros={defaultMicros} trigger={<Button type="button" variant="link" size="inline" className="text-xs">Set</Button>} />
362 </li>
363 ))}
364 </ul>
365 )}
366 <div className="mt-2.5">
367 <PersonDialog action={action} person={null} defaultMicros={defaultMicros} trigger={<Button type="button" variant="link" size="inline" className="text-xs">Give someone their own budget</Button>} />
368 </div>
369 </div>
370 );
371}
372
373/** The workspace's agent policy: every agent together, each person, a new agent, a session. Owners only. */
374function PolicyDialog({ policy, action, trigger }: { policy: AgentPolicy; action: string; trigger: ReactNode }) {
375 const { fetcher, open, setOpen, error, busy } = useDialogFetcher("spend-policy");
376 const id = useId();
377 return (
378 <Dialog open={open} onOpenChange={setOpen}>
379 <DialogTrigger asChild>{trigger}</DialogTrigger>
380 <DialogContent>
381 <DialogHeader>
382 <DialogTitle>Agent budgets</DialogTitle>
383 <DialogDescription>Monthly, reset on the 1st (UTC). The workspace&apos;s spend limit sits above all of them. Leave a budget empty for none.</DialogDescription>
384 </DialogHeader>
385 <fetcher.Form method="post" action={action} className="grid gap-5">
386 <input type="hidden" name="intent" value="policy" />
387 <Field>
388 <FieldLabel htmlFor={`${id}-monthly`}>All agents together</FieldLabel>
389 <DollarsInput id={`${id}-monthly`} name="monthly" defaultValue={dollarsField(policy.monthly_micros)} placeholder="No budget of its own" />
390 </Field>
391 <Field>
392 <FieldLabel htmlFor={`${id}-person`}>Each person</FieldLabel>
393 <DollarsInput id={`${id}-person`} name="person" defaultValue={dollarsField(policy.person_monthly_micros)} placeholder="No budget per person" />
394 <FieldDescription>What agents may spend on the work one person asks for. Give someone more or less from their row.</FieldDescription>
395 </Field>
396 <Field>
397 <FieldLabel htmlFor={`${id}-agent`}>A new agent</FieldLabel>
398 <DollarsInput id={`${id}-agent`} name="default_agent" defaultValue={dollarsField(policy.default_agent_monthly_micros)} placeholder="None" />
399 <FieldDescription>Filled in when an agent is hired; change each one on its profile.</FieldDescription>
400 </Field>
401 <Field>
402 <FieldLabel htmlFor={`${id}-session`}>A session</FieldLabel>
403 <DollarsInput id={`${id}-session`} name="default_session" defaultValue={dollarsField(policy.default_session_micros)} required />
404 <FieldDescription>Between $0.10 and $500. A session stops here and asks before spending more.</FieldDescription>
405 </Field>
406 <FieldError>{error}</FieldError>
407 <DialogFooter>
408 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
409 Cancel
410 </Button>
411 <Button type="submit" variant="accent" disabled={busy}>
412 {busy ? "Saving…" : "Save budgets"}
413 </Button>
414 </DialogFooter>
415 </fetcher.Form>
416 </DialogContent>
417 </Dialog>
418 );
419}
420
421/** One person's own budget: an amount, none at all, or back to the default. Owners only. */
422function PersonDialog({ action, person, defaultMicros, trigger }: { action: string; person: PersonBudget | null; defaultMicros: number | null; trigger: ReactNode }) {
423 const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`spend-person-${person?.username ?? "new"}`);
424 const id = useId();
425 return (
426 <Dialog open={open} onOpenChange={setOpen}>
427 <DialogTrigger asChild>{trigger}</DialogTrigger>
428 <DialogContent>
429 <DialogHeader>
430 <DialogTitle>{person ? `@${person.username}'s budget` : "A person's own budget"}</DialogTitle>
431 <DialogDescription>
432 What agents may spend on work this person asks for each month, in place of the default
433 {defaultMicros != null ? ` of ${money(defaultMicros)}` : " (none now)"}. 0 means no budget at all.
434 </DialogDescription>
435 </DialogHeader>
436 <fetcher.Form method="post" action={action} className="grid gap-5">
437 <input type="hidden" name="intent" value="person" />
438 {person ? (
439 <input type="hidden" name="username" value={person.username} />
440 ) : (
441 <Field>
442 <FieldLabel htmlFor={`${id}-username`}>Username</FieldLabel>
443 <Input id={`${id}-username`} name="username" required autoComplete="off" placeholder="ana" />
444 </Field>
445 )}
446 <Field>
447 <FieldLabel htmlFor={`${id}-amount`}>Monthly budget</FieldLabel>
448 <DollarsInput id={`${id}-amount`} name="amount" defaultValue={person?.own ? dollarsField(person.monthly_micros ?? 0) : ""} placeholder="The default" />
449 <FieldDescription>Empty puts them back on the default.</FieldDescription>
450 </Field>
451 <FieldError>{error}</FieldError>
452 <DialogFooter>
453 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
454 Cancel
455 </Button>
456 <Button type="submit" variant="accent" disabled={busy}>
457 {busy ? "Saving…" : "Save"}
458 </Button>
459 </DialogFooter>
460 </fetcher.Form>
461 </DialogContent>
462 </Dialog>
463 );
464}
465
466// ── Receipts ─────────────────────────────────────────────────────────────
467
468/** Where a task's receipt is. */
469export function receiptHref(slug: string, session: Pick<AgentSession, "id">): string {
470 return `/${slug}/-/spend/receipts/${session.id}`;
471}
472
473/** The costliest tasks, each a link to its receipt. */
474export function TaskList({ slug, sessions }: { slug: string; sessions: AgentSession[] }) {
475 if (sessions.length === 0) return <p className="px-4 py-6 text-center text-sm text-faint">No tasks in this period.</p>;
476 return (
477 <ul className="divide-y divide-line/60">
478 {sessions.map((session) => (
479 <li key={session.id} className="group relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
480 <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={24} />
481 <div className="min-w-0 grow">
482 <Link to={receiptHref(slug, session)} className="block min-w-0 truncate text-sm font-medium after:absolute after:inset-0 hover:underline">
483 {session.visible ? session.title || "Untitled session" : <PrivateTitle />}
484 </Link>
485 <p className="mt-0.5 truncate text-xs text-faint">
486 {session.agent_name}
487 {session.visible && session.asked_by_username ? ` · for @${session.asked_by_username}` : ""} · {stepsLine(session)} · <TimeAgo at={session.created_at} />
488 </p>
489 </div>
490 <span className="shrink-0 text-sm tabular-nums">{money(session.charged_micros)}</span>
491 <ArrowRight size={14} className="shrink-0 text-faint group-hover:text-muted" aria-hidden="true" />
492 </li>
493 ))}
494 </ul>
495 );
496}
497
498// ── Pricing ──────────────────────────────────────────────────────────────
499
500/** How g1t prices what it runs, from the price book. */
501export function PricingCard({ pricing }: { pricing: Pricing | null }) {
502 if (!pricing) return <p className="rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">Prices couldn&apos;t be read right now.</p>;
503 const rows: { icon: ReactNode; title: string; value: string; about: string }[] = [
504 {
505 icon: <Cpu size={15} />,
506 title: "Models",
507 value: pricing.modelMarkupPercent > 0 ? `Provider price + ${pricing.modelMarkupPercent}%` : "Provider price",
508 about: pricing.modelMarkupPercent > 0 ? "What the model provider charges, with the markup shown." : "What the model provider charges, with no markup.",
509 },
510 {
511 icon: <Sparkles size={15} />,
512 title: "Agent rate",
513 value: agentRateLabel(pricing, money),
514 about: "For what g1t runs around every model call: the model gateway, secrets, routing, context and pass-through to your own provider.",
515 },
516 {
517 icon: <Coins size={15} />,
518 title: "Everything else g1t runs",
519 value: pricing.markup ? `At cost + ${markupLabel(pricing.markup)}` : "At cost",
520 about: "Sandboxes, builds, hosting, storage, search and security scans, at what they cost g1t.",
521 },
522 {
523 icon: <KeyRound size={15} />,
524 title: "Your own model keys",
525 value: "Agent rate only",
526 about: "Your provider bills the model; g1t charges the agent rate for passing it through. Local models the same.",
527 },
528 { icon: <Building2 size={15} />, title: "Your own runners", value: "$0", about: "Time on your own hardware is never charged." },
529 { icon: <Users size={15} />, title: "People", value: "No seats", about: "Invite everyone; people chatting costs nothing. Effort and weekly suggestions help agents spend less." },
530 ];
531 return (
532 <Card asChild tone="plain" className="grid gap-px overflow-hidden bg-line sm:grid-cols-2 lg:grid-cols-3">
533 <ul>
534 {rows.map((row) => (
535 <li key={row.title} className="bg-surface p-4">
536 <p className="flex items-center gap-2 text-xs text-muted">
537 <span className="text-faint">{row.icon}</span>
538 {row.title}
539 </p>
540 <p className="mt-1 text-sm font-medium">{row.value}</p>
541 <p className="mt-0.5 text-xs text-faint">{row.about}</p>
542 </li>
543 ))}
544 </ul>
545 </Card>
546 );
547}
548
549// ── The top bar's pill ───────────────────────────────────────────────────
550
551const SCOPE_KEY = "g1t_spend_pill";
552
553function storedScope(): SpendScope {
554 try {
555 return window.localStorage.getItem(SCOPE_KEY) === "workspace" ? "workspace" : "me";
556 } catch {
557 return "me";
558 }
559}
560
561/**
562 * Your spend this month in the top bar: what agents did for you, at price,
563 * against your budget, or, for owners and billing managers who switch it,
564 * everything the workspace used at price, with what it was charged against
565 * its spend limit underneath. Both tabs count the same way, at price, so
566 * the numbers add up across them. A click opens where it went. Read after
567 * the page draws, so it adds nothing to the page's time.
568 */
569export function SpendPill({ slug, mayWorkspace }: { slug: string; mayWorkspace: boolean }) {
570 const pill = useFetcher<PillData>({ key: `spend-pill:${slug}` });
571 const [scope, setScope] = useState<SpendScope>("me");
572 const [open, setOpen] = useState(false);
573 useEffect(() => {
574 setScope(mayWorkspace ? storedScope() : "me");
575 pill.load(`/${slug}/-/spend/pill`);
576 // eslint-disable-next-line react-hooks/exhaustive-deps
577 }, [slug, mayWorkspace]);
578 useEffect(() => {
579 if (open) pill.load(`/${slug}/-/spend/pill`);
580 // eslint-disable-next-line react-hooks/exhaustive-deps
581 }, [open]);
582 const choose = (next: SpendScope) => {
583 setScope(next);
584 try {
585 window.localStorage.setItem(SCOPE_KEY, next);
586 } catch {
587 // Kept for this page only.
588 }
589 };
590 const data = pill.data;
591 const ws = scope === "workspace" ? data?.workspace : null;
592 const spent = ws ? ws.spentMicros : (data?.me?.spentMicros ?? null);
593 // The limit governs what is charged, so the meter measures that.
594 const against = ws ? ws.chargedMicros : (data?.me?.spentMicros ?? null);
595 const budget = ws ? ws.limitMicros : (data?.me?.budgetMicros ?? null);
596 const share = against != null ? shareOfBudget(against, budget) : null;
597 const label = spent == null ? (data ? "Spend" : "") : money(spent);
598 return (
599 <Popover open={open} onOpenChange={setOpen}>
600 <PopoverTrigger asChild>
601 <Button
602 variant="ghost"
603 size="sm"
604 aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`}
605 className="gap-2 text-fg-soft tabular-nums"
606 >
607 {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />}
608 {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />}
609 {share != null && (
610 <span className="hidden w-9 lg:block">
611 <Meter spent={against!} cap={budget} label={ws ? "Charged of the spend limit" : "Spent of your budget"} size="sm" />
612 </span>
613 )}
614 </Button>
615 </PopoverTrigger>
616 <PopoverContent align="end" className="w-80 p-0">
617 <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} />
618 </PopoverContent>
619 </Popover>
620 );
621}
622
623function SpendPopover({
624 slug,
625 data,
626 scope,
627 mayWorkspace,
628 onScope,
629 onClose,
630}: {
631 slug: string;
632 data: PillData | null;
633 scope: SpendScope;
634 mayWorkspace: boolean;
635 onScope: (scope: SpendScope) => void;
636 onClose: () => void;
637}) {
638 const month = data ? monthName(data.month).split(" ")[0] : "";
639 const ws = scope === "workspace" ? data?.workspace : null;
640 return (
641 <div>
642 <div className="flex items-center gap-1 border-b border-line p-1.5">
643 {(["me", "workspace"] as const).map((key) =>
644 key === "workspace" && !mayWorkspace ? (
645 <Hint key={key} label="The workspace's spend is for owners and billing managers.">
646 <span tabIndex={0} className="flex h-7 grow cursor-default items-center justify-center rounded-md text-xs text-faint">
647 Workspace
648 </span>
649 </Hint>
650 ) : (
651 <button
652 key={key}
653 type="button"
654 aria-pressed={scope === key}
655 onClick={() => onScope(key)}
656 className={cn("h-7 grow rounded-md text-xs transition-colors", scope === key ? "bg-surface text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")}
657 >
658 {key === "me" ? "You" : "Workspace"}
659 </button>
660 ),
661 )}
662 </div>
663 {!data ? (
664 <div className="space-y-2 p-4">
665 <Skeleton className="h-3 w-32" />
666 <Skeleton className="h-6 w-24" />
667 <Skeleton className="h-3 w-full" />
668 </div>
669 ) : ws ? (
670 <div className="p-4">
671 <p className="text-xs text-muted">The workspace · {month}, everything it used, at price</p>
672 <p className="mt-1 text-xl font-semibold tabular-nums">{ws.spentMicros != null ? money(ws.spentMicros) : "—"}</p>
673 {ws.chargedMicros != null && (
674 <p className="mt-1 text-xs text-muted">
675 Charged after its plan and credit: <span className="text-fg tabular-nums">{money(ws.chargedMicros)}</span>
676 {ws.limitMicros != null ? ` of a ${money(ws.limitMicros)} spend limit` : " · no spend limit set"}
677 </p>
678 )}
679 {ws.chargedMicros != null && ws.limitMicros != null && <Meter spent={ws.chargedMicros} cap={ws.limitMicros} label="Charged of the spend limit" size="sm" className="mt-2" />}
680 <p className="mt-3 text-xs text-muted">Of that, agents{ws.agentsMicros != null ? `: ${money(ws.agentsMicros)}` : ""}</p>
681 <PopoverSlices slices={ws.byAgent} />
682 </div>
683 ) : data.me ? (
684 <div className="p-4">
685 <p className="text-xs text-muted">You · {month}, what agents did for you, at price</p>
686 <p className="mt-1 text-xl font-semibold tabular-nums">
687 {money(data.me.spentMicros)}
688 {data.me.budgetMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(data.me.budgetMicros)}</span>}
689 </p>
690 {data.me.budgetMicros != null ? (
691 <Meter spent={data.me.spentMicros} cap={data.me.budgetMicros} label="Spent of your budget" size="sm" className="mt-2" />
692 ) : (
693 <p className="mt-0.5 text-xs text-faint">No budget of your own; the workspace&apos;s limits apply.</p>
694 )}
695 <PopoverSlices slices={data.me.byKind} />
696 {data.me.byAgent.length > 0 && (
697 <p className="mt-2 truncate text-xs text-faint">
698 {data.me.byAgent
699 .slice(0, 3)
700 .map((s) => `${s.label.replace(/ \(@[^)]+\)$/, "")} ${money(s.micros)}`)
701 .join(" · ")}
702 </p>
703 )}
704 </div>
705 ) : (
706 <p className="p-4 text-sm text-muted">Your spend couldn&apos;t be read right now.</p>
707 )}
708 <div className="border-t border-line p-1.5">
709 <Link
710 to={`/${slug}/-/spend${scope === "workspace" ? "" : "?scope=me"}`}
711 onClick={onClose}
712 className="flex h-8 items-center justify-between rounded-md px-2.5 text-sm text-fg/90 hover:bg-surface hover:text-fg"
713 >
714 {scope === "workspace" ? "Open workspace spend" : "Your spend and budget"}
715 <ArrowRight size={14} className="text-faint" />
716 </Link>
717 </div>
718 </div>
719 );
720}
721
722function PopoverSlices({ slices }: { slices: { key: string; label: string; micros: number }[] }) {
723 const shown = slices.filter((s) => s.micros > 0).slice(0, 4);
724 if (shown.length === 0) return <p className="mt-2 text-xs text-faint">Nothing spent yet this month.</p>;
725 return (
726 <ul className="mt-2 space-y-1">
727 {shown.map((s) => (
728 <li key={s.key || "none"} className="flex items-center justify-between gap-3 text-sm">
729 <span className="min-w-0 truncate text-fg-soft">{s.label}</span>
730 <span className="shrink-0 tabular-nums">{money(s.micros)}</span>
731 </li>
732 ))}
733 </ul>
734 );
735}