| 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 | */ |
| 7 | import { ArrowRight, Building2, Coins, Cpu, KeyRound, MessagesSquare, Puzzle, ReceiptText, Sparkles, UserRound, Users } from "lucide-react"; |
| 8 | import { type ReactNode, useEffect, useId, useState } from "react"; |
| 9 | import { Link, useFetcher } from "react-router"; |
| 10 | |
| 11 | import type { AgentPolicy, AgentSession, PersonBudget } from "@g1t/contracts"; |
| 12 | |
| 13 | import { AgentAvatar } from "./agent-avatar"; |
| 14 | import { BUTTONS, DollarsInput, useDialogFetcher } from "./agents/dialogs"; |
| 15 | import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts"; |
| 16 | import { monthName, shortDay } from "./agents/format"; |
| 17 | import { Badge } from "./ui/badge"; |
| 18 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 19 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; |
| 20 | import { Hint } from "./ui/hint"; |
| 21 | import { Input } from "./ui/input"; |
| 22 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; |
| 23 | import { Skeleton } from "./ui/skeleton"; |
| 24 | import { TimeAgo } from "./ui"; |
| 25 | import { dollarsField } from "../lib/agent-form"; |
| 26 | import { cn } from "../lib/cn"; |
| 27 | import type { AgentBudgetRow, Budgets, PillData } from "../lib/spend.server"; |
| 28 | import { AT_LIMIT, type Pricing, type SpendScope, markupLabel, percentLabel, shareOfBudget } from "../lib/spend"; |
| 29 | import { 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. */ |
| 33 | export 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 | |
| 44 | export 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 | */ |
| 58 | export 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. */ |
| 94 | export 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. */ |
| 109 | export type SliceKey = "agent" | "person" | "channel" | "model" | "kind" | "product" | "extension"; |
| 110 | |
| 111 | export 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. */ |
| 122 | export 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. */ |
| 148 | export 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 | |
| 157 | export { SliceList }; |
| 158 | |
| 159 | /** Something not built yet, said plainly where it will be. */ |
| 160 | export 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%. */ |
| 175 | function 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 | |
| 223 | const 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 | */ |
| 231 | export 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'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 | |
| 298 | function 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. */ |
| 309 | function 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. */ |
| 341 | function 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. */ |
| 372 | function 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'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. */ |
| 420 | function 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. */ |
| 467 | export 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. */ |
| 472 | export 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. */ |
| 499 | export 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'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 | |
| 547 | const SCOPE_KEY = "g1t_spend_pill"; |
| 548 | |
| 549 | function 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 | */ |
| 563 | export 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 | |
| 611 | function 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'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'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 | |
| 707 | function 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 | } |