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