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