Skip to content
732 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.

The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.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";
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.18import { Button } from "./ui/button";
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.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";
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.29import { AT_LIMIT, type Pricing, type SpendScope, agentRateLabel, markupLabel, percentLabel, shareOfBudget } from "../lib/spend";
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.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",
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.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.",
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.514 },
515 {
516 icon: <Coins size={15} />,
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.517 title: "Everything else g1t runs",
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.518 value: pricing.markup ? `At cost + ${markupLabel(pricing.markup)}` : "At cost",
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.519 about: "Sandboxes, builds, hosting, storage, search and security scans, at what they cost g1t.",
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.520 },
521 {
522 icon: <KeyRound size={15} />,
523 title: "Your own model keys",
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.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.",
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.526 },
527 { icon: <Building2 size={15} />, title: "Your own runners", value: "$0", about: "Time on your own hardware is never charged." },
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.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." },
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.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/**
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.559 * Your spend this month in the top bar: what agents did for you, at price,
560 * against your budget, or, for owners and billing managers who switch it,
561 * everything the workspace used at price, with what it was charged against
562 * its spend limit underneath. Both tabs count the same way, at price, so
563 * the numbers add up across them. A click opens where it went. Read after
564 * the page draws, so it adds nothing to the page's time.
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.565 */
566export function SpendPill({ slug, mayWorkspace }: { slug: string; mayWorkspace: boolean }) {
567 const pill = useFetcher<PillData>({ key: `spend-pill:${slug}` });
568 const [scope, setScope] = useState<SpendScope>("me");
569 const [open, setOpen] = useState(false);
570 useEffect(() => {
571 setScope(mayWorkspace ? storedScope() : "me");
572 pill.load(`/${slug}/-/spend/pill`);
573 // eslint-disable-next-line react-hooks/exhaustive-deps
574 }, [slug, mayWorkspace]);
575 useEffect(() => {
576 if (open) pill.load(`/${slug}/-/spend/pill`);
577 // eslint-disable-next-line react-hooks/exhaustive-deps
578 }, [open]);
579 const choose = (next: SpendScope) => {
580 setScope(next);
581 try {
582 window.localStorage.setItem(SCOPE_KEY, next);
583 } catch {
584 // Kept for this page only.
585 }
586 };
587 const data = pill.data;
588 const ws = scope === "workspace" ? data?.workspace : null;
589 const spent = ws ? ws.spentMicros : (data?.me?.spentMicros ?? null);
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.590 // The limit governs what is charged, so the meter measures that.
591 const against = ws ? ws.chargedMicros : (data?.me?.spentMicros ?? null);
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.592 const budget = ws ? ws.limitMicros : (data?.me?.budgetMicros ?? null);
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.593 const share = against != null ? shareOfBudget(against, budget) : null;
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.594 const label = spent == null ? (data ? "Spend" : "") : money(spent);
595 return (
596 <Popover open={open} onOpenChange={setOpen}>
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.597 <PopoverTrigger asChild>
598 <Button
599 variant="ghost"
600 size="sm"
601 aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`}
602 className="gap-2 text-fg-soft tabular-nums"
603 >
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.604 {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />}
605 {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />}
606 {share != null && (
607 <span className="hidden w-9 lg:block">
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.608 <Meter spent={against!} cap={budget} label={ws ? "Charged of the spend limit" : "Spent of your budget"} size="sm" />
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.609 </span>
610 )}
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.611 </Button>
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.612 </PopoverTrigger>
613 <PopoverContent align="end" className="w-80 p-0">
614 <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} />
615 </PopoverContent>
616 </Popover>
617 );
618}
619
620function SpendPopover({
621 slug,
622 data,
623 scope,
624 mayWorkspace,
625 onScope,
626 onClose,
627}: {
628 slug: string;
629 data: PillData | null;
630 scope: SpendScope;
631 mayWorkspace: boolean;
632 onScope: (scope: SpendScope) => void;
633 onClose: () => void;
634}) {
635 const month = data ? monthName(data.month).split(" ")[0] : "";
636 const ws = scope === "workspace" ? data?.workspace : null;
637 return (
638 <div>
639 <div className="flex items-center gap-1 border-b border-line p-1.5">
640 {(["me", "workspace"] as const).map((key) =>
641 key === "workspace" && !mayWorkspace ? (
642 <Hint key={key} label="The workspace's spend is for owners and billing managers.">
643 <span tabIndex={0} className="flex h-7 grow cursor-default items-center justify-center rounded-md text-xs text-faint">
644 Workspace
645 </span>
646 </Hint>
647 ) : (
648 <button
649 key={key}
650 type="button"
651 aria-pressed={scope === key}
652 onClick={() => onScope(key)}
653 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")}
654 >
655 {key === "me" ? "You" : "Workspace"}
656 </button>
657 ),
658 )}
659 </div>
660 {!data ? (
661 <div className="space-y-2 p-4">
662 <Skeleton className="h-3 w-32" />
663 <Skeleton className="h-6 w-24" />
664 <Skeleton className="h-3 w-full" />
665 </div>
666 ) : ws ? (
667 <div className="p-4">
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.668 <p className="text-xs text-muted">The workspace · {month}, everything it used, at price</p>
669 <p className="mt-1 text-xl font-semibold tabular-nums">{ws.spentMicros != null ? money(ws.spentMicros) : "—"}</p>
670 {ws.chargedMicros != null && (
671 <p className="mt-1 text-xs text-muted">
672 Charged after its plan and credit: <span className="text-fg tabular-nums">{money(ws.chargedMicros)}</span>
673 {ws.limitMicros != null ? ` of a ${money(ws.limitMicros)} spend limit` : " · no spend limit set"}
674 </p>
675 )}
676 {ws.chargedMicros != null && ws.limitMicros != null && <Meter spent={ws.chargedMicros} cap={ws.limitMicros} label="Charged of the spend limit" size="sm" className="mt-2" />}
677 <p className="mt-3 text-xs text-muted">Of that, agents{ws.agentsMicros != null ? `: ${money(ws.agentsMicros)}` : ""}</p>
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.678 <PopoverSlices slices={ws.byAgent} />
679 </div>
680 ) : data.me ? (
681 <div className="p-4">
The top bar's spend pill counts both tabs the same way, at price: You is what agents did for you, Workspace is everything the workspace used this month, the figure Home and Spend show, with what it was charged after its plan and credit underneath, against its spend limit; so a comped workspace reads as what it used and nothing charged, not the other way round. The spend guide says so.682 <p className="text-xs text-muted">You · {month}, what agents did for you, at price</p>
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.683 <p className="mt-1 text-xl font-semibold tabular-nums">
684 {money(data.me.spentMicros)}
685 {data.me.budgetMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(data.me.budgetMicros)}</span>}
686 </p>
687 {data.me.budgetMicros != null ? (
688 <Meter spent={data.me.spentMicros} cap={data.me.budgetMicros} label="Spent of your budget" size="sm" className="mt-2" />
689 ) : (
690 <p className="mt-0.5 text-xs text-faint">No budget of your own; the workspace&apos;s limits apply.</p>
691 )}
692 <PopoverSlices slices={data.me.byKind} />
693 {data.me.byAgent.length > 0 && (
694 <p className="mt-2 truncate text-xs text-faint">
695 {data.me.byAgent
696 .slice(0, 3)
697 .map((s) => `${s.label.replace(/ \(@[^)]+\)$/, "")} ${money(s.micros)}`)
698 .join(" · ")}
699 </p>
700 )}
701 </div>
702 ) : (
703 <p className="p-4 text-sm text-muted">Your spend couldn&apos;t be read right now.</p>
704 )}
705 <div className="border-t border-line p-1.5">
706 <Link
707 to={`/${slug}/-/spend${scope === "workspace" ? "" : "?scope=me"}`}
708 onClick={onClose}
709 className="flex h-8 items-center justify-between rounded-md px-2.5 text-sm text-fg/90 hover:bg-surface hover:text-fg"
710 >
711 {scope === "workspace" ? "Open workspace spend" : "Your spend and budget"}
712 <ArrowRight size={14} className="text-faint" />
713 </Link>
714 </div>
715 </div>
716 );
717}
718
719function PopoverSlices({ slices }: { slices: { key: string; label: string; micros: number }[] }) {
720 const shown = slices.filter((s) => s.micros > 0).slice(0, 4);
721 if (shown.length === 0) return <p className="mt-2 text-xs text-faint">Nothing spent yet this month.</p>;
722 return (
723 <ul className="mt-2 space-y-1">
724 {shown.map((s) => (
725 <li key={s.key || "none"} className="flex items-center justify-between gap-3 text-sm">
726 <span className="min-w-0 truncate text-fg-soft">{s.label}</span>
727 <span className="shrink-0 tabular-nums">{money(s.micros)}</span>
728 </li>
729 ))}
730 </ul>
731 );
732}