Skip to content
712 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Usage, Billing settings and prepaid AI credit; fixes from the UX audit1/**
2 * The Usage page's pieces, shared by the workspace's page and a project's:
3 * the filters, the included-usage and credit bar with what paid, the
4 * consumption chart (stacked columns by product, money on one axis, a
5 * tooltip per column, a legend, a table view), and the breakdown by
6 * product family with sparklines, allowance rings and each meter's
7 * projects. Every amount is usage at price, as on every other page.
8 */
9import { ChevronDown, Download, Ellipsis, FileCode } from "lucide-react";
10import { type ReactNode, useId, useRef } from "react";
11import { Form, Link } from "react-router";
12
13import type { MeterLine, ProductUsage, UsageReport } from "@g1t/contracts";
14
15import {
16 type Column,
17 type Grain,
18 type GroupBy,
19 PERIODS,
20 type Period,
21 axisMoney,
22 byProject,
23 money,
24 productStyle,
25 quantity,
26 rangeLabel,
27 receipt,
28 shownLines,
29 ticks,
30 usageCsv,
31} from "../lib/usage";
32import { cn } from "../lib/cn";
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.33import { Checkbox } from "./ui/checkbox";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit34import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
Usage: a meter's note is the site's tooltip, caught by the no-native-title test35import { Hint } from "./ui/hint";
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.36import { Input } from "./ui/input";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project37import { SelectField } from "./ui/select";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit38import { Skeleton } from "./ui/skeleton";
39
40const SELECT =
41 "h-8 rounded-md border border-line bg-bg px-2 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
42
43// --- Filters ------------------------------------------------------------------------
44
45export type UsageFilters = {
46 period: Period;
47 from: string;
48 until: string;
49 products: string[];
50 projects: string[];
51 group: GroupBy;
52 grain: Grain;
53 cumulative: boolean;
54};
55
56/** A multi-select as a disclosure of checkboxes, part of the filter form. */
57function MultiSelect({ name, label, options, chosen }: { name: string; label: string; options: { value: string; label: string }[]; chosen: string[] }) {
58 const summary = chosen.length === 0 ? `All ${label.toLowerCase()}` : chosen.length === 1 ? (options.find((o) => o.value === chosen[0])?.label ?? chosen[0]) : `${chosen.length} ${label.toLowerCase()}`;
59 return (
60 <details className="group relative">
61 <summary className={cn(SELECT, "flex cursor-pointer list-none items-center gap-1.5 [&::-webkit-details-marker]:hidden")}>
62 <span className="text-muted">{label}:</span>
63 <span className="max-w-[10rem] truncate">{summary}</span>
64 <ChevronDown size={13} className="text-muted transition-transform group-open:rotate-180" />
65 </summary>
66 <div className="absolute left-0 z-20 mt-1 w-64 rounded-lg border border-line bg-raised p-2 shadow-xl">
67 {options.length === 0 ? (
68 <p className="px-2 py-1.5 text-sm text-faint">None in this range.</p>
69 ) : (
70 <ul className="max-h-64 overflow-y-auto">
71 {options.map((option) => (
72 <li key={option.value}>
73 <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface">
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.74 <Checkbox name={name} value={option.value} defaultChecked={chosen.includes(option.value)} />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit75 <span className="truncate">{option.label}</span>
76 </label>
77 </li>
78 ))}
79 </ul>
80 )}
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.81 <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover">
Usage, Billing settings and prepaid AI credit; fixes from the UX audit82 Apply
83 </button>
84 </div>
85 </details>
86 );
87}
88
89/**
90 * The filters, in one row above everything: period (with its days),
91 * products, projects, group by, and a menu with the CSV and the API.
92 * A plain GET form, so a filtered page is a link.
93 */
94export function UsageFilterBar({
95 filters,
96 products,
97 projects,
98 report,
99 apiHref,
100 fixedProject,
101}: {
102 filters: UsageFilters;
103 products: { key: string; label: string }[];
104 projects: string[];
105 report: UsageReport | null;
106 apiHref: string;
107 /** A project's own page: no project filter. */
108 fixedProject?: string;
109}) {
110 const form = useRef<HTMLFormElement>(null);
111 const submit = () => form.current?.requestSubmit();
112 const download = () => {
113 if (!report) return;
114 const blob = new Blob([usageCsv(report)], { type: "text/csv" });
115 const link = document.createElement("a");
116 link.href = URL.createObjectURL(blob);
117 link.download = `usage-${report.from}-${report.until}.csv`;
118 link.click();
119 URL.revokeObjectURL(link.href);
120 };
121 return (
122 <Form ref={form} method="get" preventScrollReset className="flex flex-wrap items-center gap-2">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project123 <SelectField
124 key={`period-${filters.period}`}
125 name="period"
126 defaultValue={filters.period}
127 afterChange={submit}
128 size="sm"
129 className="w-auto"
130 aria-label="Period"
131 options={(Object.keys(PERIODS) as Period[]).map((key) => ({ value: key, label: PERIODS[key] }))}
132 />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit133 {filters.period === "custom" ? (
134 <span className="flex items-center gap-1 text-sm">
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.135 <Input type="date" name="from" defaultValue={filters.from} aria-label="From" className="h-8 w-auto px-2" />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit136 <span className="text-faint">to</span>
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.137 <Input type="date" name="until" defaultValue={filters.until} aria-label="Until" className="h-8 w-auto px-2" />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit138 </span>
139 ) : (
140 <span className="px-1 text-sm text-muted tabular-nums">{rangeLabel(filters.from, filters.until)}</span>
141 )}
142 <MultiSelect name="product" label="Products" options={products.map((p) => ({ value: p.key, label: p.label }))} chosen={filters.products} />
143 {!fixedProject && <MultiSelect name="project" label="Projects" options={projects.map((p) => ({ value: p, label: p }))} chosen={filters.projects} />}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project144 <SelectField
145 key={`group-${filters.group}`}
146 name="group"
147 defaultValue={filters.group}
148 afterChange={submit}
149 size="sm"
150 className="w-auto"
151 aria-label="Group by"
152 options={[
153 { value: "product", label: "Group by product" },
154 ...(fixedProject ? [] : [{ value: "project", label: "Group by project" }]),
155 { value: "day", label: "Group by day" },
156 ]}
157 />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit158 <input type="hidden" name="grain" value={filters.grain} />
159 {filters.cumulative && <input type="hidden" name="cumulative" value="1" />}
160 <noscript>
161 <button type="submit" className={SELECT}>
162 Apply
163 </button>
164 </noscript>
165 <span className="ml-auto" />
166 <DropdownMenu>
167 <DropdownMenuTrigger className={cn(SELECT, "inline-flex items-center")} aria-label="More">
168 <Ellipsis size={15} />
169 </DropdownMenuTrigger>
170 <DropdownMenuContent align="end">
171 <DropdownMenuItem onSelect={download} disabled={!report}>
172 <Download size={14} /> Export CSV
173 </DropdownMenuItem>
174 <DropdownMenuItem asChild>
175 <a href={apiHref}>
176 <FileCode size={14} /> Usage API
177 </a>
178 </DropdownMenuItem>
179 </DropdownMenuContent>
180 </DropdownMenu>
181 </Form>
182 );
183}
184
185// --- What is included and what paid ------------------------------------------------------
186
187/** A bar of how much of something is used. */
188function Bar({ used, of, tone = "accent" }: { used: number; of: number; tone?: "accent" | "warn" }) {
189 const part = of > 0 ? Math.min(1, used / of) : 0;
190 return (
191 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
Merge branch 'worktree-agent-ad7c6d88d93adc817'192 <div className={cn("h-full rounded-full", tone === "warn" ? "bg-warn" : "bg-success")} style={{ width: `${part > 0 ? Math.max(2, part * 100) : 0}%` }} />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit193 </div>
194 );
195}
196
197/**
198 * The plan's included usage, the credit balances, and the receipt: usage
199 * at price, what a discount, included usage and credit took off, and what
200 * is charged.
201 */
202export function IncludedAndCredit({ report, billingHref, owner }: { report: UsageReport; billingHref: string; owner: boolean }) {
203 const lines = receipt(report.totals, report.discountPercent);
204 const free = report.plan === "free";
205 const fullDiscount = (report.discountPercent ?? 0) >= 100;
206 return (
207 <section className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-3">
208 <div className="bg-surface p-4">
209 <p className="text-sm text-muted">{report.included ? "Included usage this month" : free ? "Trial credit" : "Plan"}</p>
210 {report.included ? (
211 <>
212 <p className="mt-1 text-lg font-semibold tabular-nums">
213 {money(report.included.used)} <span className="text-sm font-normal text-faint">/ {money(report.included.of)}</span>
214 </p>
215 <Bar used={report.included.used} of={report.included.of} />
216 </>
217 ) : free ? (
218 <>
219 <p className="mt-1 text-lg font-semibold tabular-nums">{report.trialMicros != null ? `${money(report.trialMicros)} left` : "None"}</p>
220 <p className="mt-1 text-xs text-faint">
221 The forge is free. Compute needs the plan or the trial.{" "}
222 <Link to={billingHref} className="text-fg-soft underline-offset-2 hover:underline">
223 See plans
224 </Link>
225 </p>
226 </>
227 ) : (
228 <p className="mt-1 text-sm">{fullDiscount ? `${report.discountPercent}% discount from g1t` : report.plan === "enterprise" ? "Paid by an enterprise" : "On the g1t plan"}</p>
229 )}
230 </div>
231 <div className="bg-surface p-4">
232 <p className="text-sm text-muted">Credit</p>
233 {fullDiscount ? (
234 <p className="mt-1 text-sm">AI usage is free under the {report.discountPercent}% discount: nothing to buy.</p>
235 ) : (
236 <dl className="mt-1 space-y-1 text-sm">
237 <div className="flex justify-between gap-3">
238 <dt className="text-muted">AI credit</dt>
239 <dd className="font-medium tabular-nums">{money(report.aiCreditMicros)}</dd>
240 </div>
241 {report.creditMicros > 0 && (
242 <div className="flex justify-between gap-3">
243 <dt className="text-muted">Credit from g1t</dt>
244 <dd className="tabular-nums">{money(report.creditMicros)}</dd>
245 </div>
246 )}
247 {owner && report.plan === "paid" && (
248 <dd>
249 <Link to={`${billingHref}#ai-credit`} className="text-xs text-fg-soft underline-offset-2 hover:underline">
250 Buy AI credit or turn on auto-reload
251 </Link>
252 </dd>
253 )}
254 </dl>
255 )}
256 </div>
257 <div className="bg-surface p-4">
258 <p className="text-sm text-muted">This range</p>
259 <dl className="mt-1 space-y-1 text-sm">
260 {lines.map((line, i) => (
261 <div key={line.label} className={cn("flex justify-between gap-3", i === lines.length - 1 && "border-t border-line pt-1 font-medium")}>
262 <dt className={i === lines.length - 1 ? "" : "text-muted"}>{line.label}</dt>
263 <dd className="tabular-nums">
264 {line.minus ? "−" : ""}
265 {money(line.micros)}
266 </dd>
267 </div>
268 ))}
269 </dl>
270 {report.totals.pendingMicros > 0 && (
271 <p className="mt-1 text-xs text-faint">{money(report.totals.pendingMicros)} of it is metered this month and charged when it closes.</p>
272 )}
273 </div>
274 </section>
275 );
276}
277
278// --- The chart -----------------------------------------------------------------------------
279
280const CHART_HEIGHT = 200;
281
282/**
283 * Consumption: one column per day (or week, or month), stacked by product,
284 * money on the one axis. Hovering or focusing a column shows its products;
285 * the legend names them; the table view has every number.
286 */
287export function UsageChart({
288 columns,
289 products,
290 grain,
291 cumulative,
292 grainHref,
293 cumulativeHref,
294}: {
295 columns: Column[];
296 products: ProductUsage[];
297 grain: Grain;
298 cumulative: boolean;
299 grainHref: (grain: Grain) => string;
300 cumulativeHref: string;
301}) {
302 const used = products.filter((p) => p.micros !== 0 && columns.some((c) => (c.parts[p.key] ?? 0) !== 0));
303 const max = Math.max(0, ...columns.map((c) => c.total));
304 const scale = ticks(max);
305 const top = scale[scale.length - 1] || 1;
306 const id = useId();
307 const labelEvery = Math.max(1, Math.ceil(columns.length / 6));
308 return (
309 <section className="rounded-xl border border-line bg-surface p-4 sm:p-5" aria-labelledby={`${id}-title`}>
310 <div className="flex flex-wrap items-center justify-between gap-3">
311 <h2 id={`${id}-title`} className="font-medium">
312 Consumption
313 </h2>
314 <div className="flex flex-wrap items-center gap-2 text-sm">
315 <nav className="flex rounded-md border border-line p-0.5" aria-label="Columns">
316 {(["day", "week", "month"] as Grain[]).map((g) => (
317 <Link
318 key={g}
319 to={grainHref(g)}
320 preventScrollReset
321 aria-current={g === grain ? "true" : undefined}
322 className={cn("rounded px-2 py-0.5 capitalize", g === grain ? "bg-raised text-fg" : "text-muted hover:text-fg")}
323 >
324 {g === "day" ? "Daily" : g === "week" ? "Weekly" : "Monthly"}
325 </Link>
326 ))}
327 </nav>
328 <Link to={cumulativeHref} preventScrollReset className="flex items-center gap-1.5 text-muted hover:text-fg" role="checkbox" aria-checked={cumulative}>
329 <span className={cn("grid size-3.5 place-items-center rounded-sm border", cumulative ? "border-accent bg-accent text-bg" : "border-line-strong")}>
330 {cumulative && <span className="text-[0.6rem] leading-none">✓</span>}
331 </span>
332 Cumulative
333 </Link>
334 </div>
335 </div>
336 {used.length > 0 && (
337 <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
338 {used.map((p) => (
339 <li key={p.key} className="flex items-center gap-1.5">
340 <span className="size-2.5 rounded-sm" style={{ background: productStyle(p.key).color }} />
341 {p.label}
342 </li>
343 ))}
344 </ul>
345 )}
346 <div className="mt-4 flex gap-2">
347 {/* The one money axis: clean ticks, every label different. */}
348 <div className="relative w-12 shrink-0 text-right text-[0.6875rem] text-faint tabular-nums" style={{ height: CHART_HEIGHT }} aria-hidden="true">
349 {scale.map((t) => (
350 <span key={t} className="absolute right-0 -translate-y-1/2" style={{ top: `${100 - (t / top) * 100}%` }}>
351 {axisMoney(t)}
352 </span>
353 ))}
354 </div>
355 <div className="relative min-w-0 flex-1" style={{ height: CHART_HEIGHT }}>
356 {scale.map((t) => (
357 <div key={t} className="absolute inset-x-0 border-t border-line" style={{ top: `${100 - (t / top) * 100}%` }} aria-hidden="true" />
358 ))}
359 <div className="absolute inset-0 flex items-end" role="list" aria-label="Usage per column">
360 {columns.map((column, i) => (
361 <div
362 key={column.key}
363 role="listitem"
364 tabIndex={0}
365 aria-label={`${column.label}: ${money(column.total)}`}
366 className="group relative flex h-full min-w-0 flex-1 flex-col items-center justify-end outline-none"
367 >
368 <div className="flex w-full max-w-6 flex-col-reverse gap-[2px] px-[1px]" style={{ height: `${(column.total / top) * 100}%` }}>
369 {used
370 .filter((p) => (column.parts[p.key] ?? 0) > 0)
371 .map((p, at, all) => (
372 <div
373 key={p.key}
374 className={cn("w-full min-h-[2px]", at === all.length - 1 && "rounded-t")}
375 style={{ flexGrow: column.parts[p.key] ?? 0, flexBasis: 0, background: productStyle(p.key).color }}
376 />
377 ))}
378 </div>
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.379 <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-fg/[0.04] group-focus-visible:bg-fg/[0.06]" aria-hidden="true" />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit380 <div
381 className={cn(
382 "pointer-events-none absolute bottom-full z-10 mb-2 hidden w-52 rounded-lg border border-line-strong bg-raised p-2.5 text-xs shadow-xl group-hover:block group-focus-visible:block",
383 i < columns.length / 2 ? "left-0" : "right-0",
384 )}
385 role="tooltip"
386 >
387 <p className="flex justify-between font-medium">
388 <span>{column.label}</span>
389 <span className="tabular-nums">{money(column.total)}</span>
390 </p>
391 <ul className="mt-1.5 space-y-1">
392 {used.map((p) => (
393 <li key={p.key} className="flex items-center justify-between gap-2 text-muted">
394 <span className="flex items-center gap-1.5">
395 <span className="size-2 rounded-sm" style={{ background: productStyle(p.key).color }} />
396 {p.label}
397 </span>
398 <span className="tabular-nums text-fg-soft">{money(column.parts[p.key] ?? 0)}</span>
399 </li>
400 ))}
401 </ul>
402 </div>
403 </div>
404 ))}
405 </div>
406 </div>
407 </div>
408 <div className="ml-14 mt-1.5 flex text-[0.6875rem] text-faint" aria-hidden="true">
409 {columns.map((column, i) => (
410 <span key={column.key} className="min-w-0 flex-1 truncate text-center">
411 {i % labelEvery === 0 ? column.label : ""}
412 </span>
413 ))}
414 </div>
415 {max === 0 && <p className="mt-3 text-center text-sm text-faint">Nothing used in this range.</p>}
416 <details className="mt-3 text-sm">
417 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Show as a table</summary>
418 <div className="mt-2 max-h-72 overflow-auto">
419 <table className="w-full text-left text-xs tabular-nums">
420 <thead className="sticky top-0 bg-surface text-muted">
421 <tr>
422 <th className="py-1 pr-3 font-medium">{grain === "day" ? "Day" : grain === "week" ? "Week of" : "Month"}</th>
423 {used.map((p) => (
424 <th key={p.key} className="py-1 pr-3 text-right font-medium">
425 {p.label}
426 </th>
427 ))}
428 <th className="py-1 text-right font-medium">{cumulative ? "Running total" : "Total"}</th>
429 </tr>
430 </thead>
431 <tbody className="divide-y divide-line">
432 {columns.map((column) => (
433 <tr key={column.key}>
434 <td className="py-1 pr-3">{column.label}</td>
435 {used.map((p) => (
436 <td key={p.key} className="py-1 pr-3 text-right">
437 {money(column.parts[p.key] ?? 0)}
438 </td>
439 ))}
440 <td className="py-1 text-right">{money(column.total)}</td>
441 </tr>
442 ))}
443 </tbody>
444 </table>
445 </div>
446 </details>
447 </section>
448 );
449}
450
451// --- The breakdown ---------------------------------------------------------------------------
452
453/** A meter's days as a small line, in the product's color. */
454export function Sparkline({ values, color }: { values: number[]; color: string }) {
455 const max = Math.max(...values, 0);
456 if (values.length < 2 || max <= 0) return <span className="block h-5 w-20" aria-hidden="true" />;
457 const points = values.map((v, i) => `${(i / (values.length - 1)) * 78 + 1},${19 - (v / max) * 17}`).join(" ");
458 return (
459 <svg viewBox="0 0 80 20" className="h-5 w-20" aria-hidden="true">
460 <polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
461 </svg>
462 );
463}
464
465/** How much of an allowance is used, as a ring. */
466export function AllowanceRing({ used, of }: { used: number; of: number }) {
467 const part = of > 0 ? Math.min(1, used / of) : 0;
468 const r = 7;
469 const c = 2 * Math.PI * r;
470 return (
471 <svg viewBox="0 0 18 18" className="size-4 shrink-0 -rotate-90" aria-hidden="true">
472 <circle cx="9" cy="9" r={r} fill="none" stroke="var(--g1t-line-strong)" strokeWidth="2.5" />
Merge branch 'worktree-agent-ad7c6d88d93adc817'473 <circle cx="9" cy="9" r={r} fill="none" stroke={part >= 1 ? "var(--g1t-warn)" : "var(--g1t-success)"} strokeWidth="2.5" strokeDasharray={`${part * c} ${c}`} strokeLinecap="round" />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit474 </svg>
475 );
476}
477
478function MeterUsed({ meter }: { meter: MeterLine }) {
479 if (meter.allowance) {
480 const unit = meter.allowance.unit;
481 return (
482 <span className="flex items-center gap-1.5 sm:justify-end">
483 <AllowanceRing used={meter.allowance.used} of={meter.allowance.of} />
484 <span>
485 {quantity(meter.allowance.used, unit)} <span className="text-faint">/ {quantity(meter.allowance.of, unit).replace(/ (operations|entries)$/, "")} free</span>
486 </span>
487 </span>
488 );
489 }
490 return <span>{meter.quantity ? quantity(meter.quantity, meter.unit) : <span className="text-faint">—</span>}</span>;
491}
492
493const ROW = "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 px-4 py-2.5 sm:grid-cols-[minmax(0,1fr)_5.5rem_14rem_6rem]";
494
495/** One meter: its days, how much was used and its charge; open, its projects. */
496function MeterRow({ meter, color, projectHref }: { meter: MeterLine; color: string; projectHref?: (project: string) => string }) {
497 const parts = meter.byProject.filter((p) => p.micros !== 0 || p.quantity !== 0);
498 const body = (
499 <>
500 <span className="flex min-w-0 items-center gap-2">
501 <span className="size-2 shrink-0 rounded-sm" style={{ background: color }} />
Merge branch 'model-routing'502 <span className="min-w-0">
503 <span className="block truncate">{meter.label}</span>
Usage: a meter's note is the site's tooltip, caught by the no-native-title test504 {meter.note && (
505 <Hint label={meter.note}>
506 <span className="block truncate text-[0.6875rem] text-faint">{meter.note}</span>
507 </Hint>
508 )}
Merge branch 'model-routing'509 </span>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit510 {parts.length > 0 && <ChevronDown size={13} className="shrink-0 text-faint transition-transform group-open:rotate-180" />}
511 </span>
512 <span className="hidden sm:block">
513 <Sparkline values={meter.daily} color={color} />
514 </span>
515 <span className="order-3 col-span-2 text-sm text-muted tabular-nums sm:order-none sm:col-span-1 sm:text-right">
516 <MeterUsed meter={meter} />
517 </span>
518 <span className="text-right tabular-nums">
519 {money(meter.micros)}
520 {(meter.pendingMicros ?? 0) > 0 && <span className="block text-[0.6875rem] text-faint">{money(meter.pendingMicros ?? 0)} pending</span>}
521 </span>
522 </>
523 );
524 if (parts.length === 0) return <li className={cn(ROW, "text-sm")}>{body}</li>;
525 return (
526 <li>
527 <details className="group">
528 <summary className={cn(ROW, "cursor-pointer list-none text-sm hover:bg-raised/50 [&::-webkit-details-marker]:hidden")}>{body}</summary>
529 <ul className="border-t border-line bg-bg/40 py-1">
530 {parts.map((part) => (
531 <li key={part.project || "none"} className="flex justify-between gap-3 py-1.5 pl-10 pr-4 text-sm text-muted">
532 <span className="truncate">
533 {part.project ? (
534 projectHref ? (
535 <Link to={projectHref(part.project)} className="hover:text-fg hover:underline">
536 {part.project}
537 </Link>
538 ) : (
539 part.project
540 )
541 ) : (
542 "Not tied to a project"
543 )}
544 </span>
545 <span className="flex gap-4 tabular-nums">
546 {part.quantity > 0 && meter.unit !== "entries" && <span className="hidden sm:inline">{quantity(part.quantity, meter.unit)}</span>}
547 <span className="text-fg-soft">{money(part.micros)}</span>
548 </span>
549 </li>
550 ))}
551 </ul>
552 </details>
553 </li>
554 );
555}
556
557/** The breakdown, by product family (or by project, or by day). */
558export function Breakdown({
559 report,
560 group,
561 columns,
562 projectHref,
563}: {
564 report: UsageReport;
565 group: GroupBy;
566 columns: Column[];
567 projectHref?: (project: string) => string;
568}) {
569 const header = (first: string) => (
570 <div className={cn(ROW, "border-b border-line py-2 text-xs text-muted")}>
571 <span>{first}</span>
572 <span className="hidden sm:block">Trend</span>
573 <span className="hidden text-right sm:block">Usage</span>
574 <span className="text-right">Charge at price</span>
575 </div>
576 );
577 if (group === "project") {
578 const rows = byProject(report);
579 return (
580 <section className="overflow-hidden rounded-xl border border-line bg-surface">
581 <div className="grid grid-cols-[minmax(0,1fr)_auto] border-b border-line px-4 py-2 text-xs text-muted">
582 <span>Project</span>
583 <span>Charge at price</span>
584 </div>
585 {rows.length === 0 ? (
586 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
587 ) : (
588 <ul className="divide-y divide-line">
589 {rows.map((row) => (
590 <li key={row.project || "none"} className="px-4 py-2.5 text-sm">
591 <div className="flex justify-between gap-3">
592 <span className="truncate font-medium">
593 {row.project ? projectHref ? <Link to={projectHref(row.project)} className="hover:underline">{row.project}</Link> : row.project : "Not tied to a project"}
594 </span>
595 <span className="tabular-nums">{money(row.micros)}</span>
596 </div>
597 <p className="mt-0.5 text-xs text-faint">{row.meters.map((m) => `${m.label} ${money(m.micros)}`).join(" · ")}</p>
598 </li>
599 ))}
600 </ul>
601 )}
602 </section>
603 );
604 }
605 if (group === "day") {
606 const days = columns.filter((c) => c.total !== 0).reverse();
607 return (
608 <section className="overflow-hidden rounded-xl border border-line bg-surface">
609 <div className="grid grid-cols-[minmax(0,1fr)_auto] border-b border-line px-4 py-2 text-xs text-muted">
610 <span>Day</span>
611 <span>Charge at price</span>
612 </div>
613 {days.length === 0 ? (
614 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
615 ) : (
616 <ul className="divide-y divide-line">
617 {days.map((column) => (
618 <li key={column.key} className="px-4 py-2.5 text-sm">
619 <div className="flex justify-between gap-3">
620 <span className="font-medium">{column.label}</span>
621 <span className="tabular-nums">{money(column.total)}</span>
622 </div>
623 <p className="mt-0.5 text-xs text-faint">
624 {Object.entries(column.parts)
625 .filter(([, micros]) => micros !== 0)
626 .map(([product, micros]) => `${productStyle(product).label} ${money(micros)}`)
627 .join(" · ")}
628 </p>
629 </li>
630 ))}
631 </ul>
632 )}
633 </section>
634 );
635 }
636 const products = report.products.filter((p) => shownLines(p.meters).length > 0);
637 return (
638 <section className="overflow-hidden rounded-xl border border-line bg-surface">
639 {header("Product")}
640 {products.length === 0 ? (
641 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
642 ) : (
643 products.map((product) => {
644 const color = productStyle(product.key).color;
645 return (
646 <div key={product.key} className="border-b border-line last:border-b-0">
647 <div className="flex items-baseline justify-between gap-3 bg-raised/40 px-4 py-2">
648 <h3 className="text-sm font-medium">{product.label}</h3>
649 <span className="text-sm font-medium tabular-nums">{money(product.micros)}</span>
650 </div>
651 {product.features && product.features.length > 0 && (
652 <p className="px-4 pt-2 text-xs text-faint">
653 {product.features.map((f) => `${f.label}${f.count ? ` (${f.count})` : ""} ${money(f.micros)}`).join(" · ")}
654 </p>
655 )}
Merge branch 'model-routing'656 {product.key === "agent" && report.models && report.models.length > 0 && (
657 <p className="px-4 pt-1 text-xs text-faint">
658 Tokens by model:{" "}
659 {report.models.map((m) => `${m.model} ${quantity(m.input + m.output + m.cacheRead + m.cacheWrite, "tokens")}`).join(" · ")}
660 </p>
661 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit662 <ul className="divide-y divide-line/60">
663 {shownLines(product.meters).map((meter) => (
664 <MeterRow key={meter.key} meter={meter} color={color} projectHref={projectHref} />
665 ))}
666 </ul>
667 </div>
668 );
669 })
670 )}
671 </section>
672 );
673}
674
675// --- Loading --------------------------------------------------------------------------------
676
677/** Each region's shape while the report is on its way: the same heights, so nothing moves. */
678export function UsageSkeleton({ children }: { children?: ReactNode }) {
679 return (
680 <div className="space-y-6" aria-busy="true">
681 <span role="status" className="sr-only">
682 Loading usage…
683 </span>
684 {children}
685 <div className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-3" aria-hidden="true">
686 {[0, 1, 2].map((i) => (
687 <div key={i} className="h-[7.5rem] bg-surface p-4">
688 <Skeleton className="h-3 w-28" />
689 <Skeleton className="mt-3 h-5 w-20" />
690 <Skeleton className="mt-3 h-1.5 w-full" />
691 </div>
692 ))}
693 </div>
694 <div className="rounded-xl border border-line bg-surface p-5" aria-hidden="true">
695 <Skeleton className="h-4 w-28" />
696 <div className="mt-6 flex h-[200px] items-end gap-1">
697 {Array.from({ length: 24 }, (_, i) => (
698 <Skeleton key={i} className="flex-1 rounded-t" style={{ height: `${20 + ((i * 37) % 60)}%` }} />
699 ))}
700 </div>
701 </div>
702 <div className="rounded-xl border border-line bg-surface" aria-hidden="true">
703 {[0, 1, 2, 3, 4].map((i) => (
704 <div key={i} className="flex items-center justify-between border-b border-line px-4 py-3 last:border-b-0">
705 <Skeleton className="h-3 w-40" />
706 <Skeleton className="h-3 w-16" />
707 </div>
708 ))}
709 </div>
710 </div>
711 );
712}

This file's history is long; its oldest lines are credited to the oldest commit read.