| 1 | import { ArrowRight } from "lucide-react"; |
| 2 | import { type ReactNode, Suspense } from "react"; |
| 3 | import { Await, Link, data } from "react-router"; |
| 4 | |
| 5 | import type { AgentPolicy, AgentSpendBreakdown, SpendSlice, UsageReport } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/spend"; |
| 8 | import { answer, agentsAction } from "../../components/agents/actions.server"; |
| 9 | import { RowsSkeleton } from "../../components/today"; |
| 10 | import { |
| 11 | BudgetLadder, |
| 12 | ComingNote, |
| 13 | PricingCard, |
| 14 | Section, |
| 15 | SliceList, |
| 16 | SlicePanel, |
| 17 | type SliceKey, |
| 18 | SLICES, |
| 19 | SliceTabs, |
| 20 | SpendBars, |
| 21 | TaskList, |
| 22 | Tile, |
| 23 | TileSkeleton, |
| 24 | } from "../../components/spend"; |
| 25 | import { Meter } from "../../components/agents/parts"; |
| 26 | import { Savings } from "../../components/effort"; |
| 27 | import { readOrNull } from "../../components/agents/actions.server"; |
| 28 | import { Skeleton } from "../../components/ui/skeleton"; |
| 29 | import { microsFromDollars } from "../../lib/agent-form"; |
| 30 | import { cn } from "../../lib/cn"; |
| 31 | import { page } from "../../lib/meta"; |
| 32 | import { workspaceAgents } from "../../lib/services.server"; |
| 33 | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; |
| 34 | import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend"; |
| 35 | import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server"; |
| 36 | import { money } from "../../lib/usage"; |
| 37 | |
| 38 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 39 | return page(args, { title: `Spend · ${params.owner} · g1t` }); |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Spend: the front of the workspace's money. What was spent over a |
| 44 | * period, where it went (by agent, person, channel, model, kind of work and |
| 45 | * product), the budgets from the workspace down to one task with what |
| 46 | * happens at each, the costliest tasks with their receipts, and how it is |
| 47 | * priced. Owners and billing managers see the workspace's, or their own; |
| 48 | * everyone else sees their own: what agents did for them. Each part |
| 49 | * streams in from its own service. Definitions: lib/spend.ts. |
| 50 | */ |
| 51 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 52 | const viewer = requireUser(context, request); |
| 53 | const slug = params.owner.toLowerCase(); |
| 54 | const role = roleIn(viewer, slug); |
| 55 | if (!role) throw data(null, { status: 404 }); |
| 56 | const url = new URL(request.url); |
| 57 | const mayWorkspace = managesBilling(viewer, slug); |
| 58 | const scope = readScope(url.searchParams.get("scope"), mayWorkspace); |
| 59 | const period = readPeriod(url.searchParams.get("period")); |
| 60 | const by = readSlice(url.searchParams.get("by"), scope); |
| 61 | const now = new Date(); |
| 62 | |
| 63 | const breakdown = loadBreakdown(viewer, slug, scope, period); |
| 64 | // The budgets are monthly: every agent's month, read once when the page is on it already. |
| 65 | const month = scope === "workspace" && period === "month" ? breakdown : loadBreakdown(viewer, slug, "workspace", "month"); |
| 66 | const usage = scope === "workspace" ? loadUsage(viewer, slug, period, now) : Promise.resolve(null); |
| 67 | return { |
| 68 | slug, |
| 69 | me: viewer.username.toLowerCase(), |
| 70 | owner: role === "owner", |
| 71 | mayWorkspace, |
| 72 | scope, |
| 73 | period, |
| 74 | by, |
| 75 | span: spanFor(period, now), |
| 76 | breakdown, |
| 77 | usage, |
| 78 | budgets: loadBudgets(viewer, slug, month), |
| 79 | pricing: loadPricing(), |
| 80 | // Spend less, keep quality: the weekly check's suggestions. |
| 81 | recs: readOrNull(workspaceAgents.recommendations(slug, viewer)), |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | function readSlice(raw: string | null, scope: SpendScope): SliceKey { |
| 86 | const found = SLICES.find((s) => s.key === raw && (scope === "workspace" || !s.workspaceOnly)); |
| 87 | return found?.key ?? "agent"; |
| 88 | } |
| 89 | |
| 90 | /** Owners set the agent budgets: every agent together, each person, a new agent, a session; and one person's own. */ |
| 91 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 92 | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); |
| 93 | const intent = form.get("intent"); |
| 94 | if (intent === "recommendation") { |
| 95 | if (!isOwner) return { ok: false as const, intent, error: "Only the workspace's owners apply suggestions." }; |
| 96 | const what = form.get("do") === "apply" ? "apply" : "dismiss"; |
| 97 | return answer(intent, workspaceAgents.resolveRecommendation(slug, viewer, String(form.get("id") ?? ""), what)); |
| 98 | } |
| 99 | if (!isOwner) return { ok: false as const, error: "Only the workspace's owners set budgets." }; |
| 100 | if (intent === "policy") { |
| 101 | const monthly = microsFromDollars(form.get("monthly")); |
| 102 | const person = microsFromDollars(form.get("person")); |
| 103 | const agent = microsFromDollars(form.get("default_agent")); |
| 104 | const session = microsFromDollars(form.get("default_session")); |
| 105 | if ([monthly, person, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, intent, error: "Write each amount in dollars, such as 250 or 2.50." }; |
| 106 | const changes: Partial<AgentPolicy> = { monthly_micros: monthly, person_monthly_micros: person, default_agent_monthly_micros: agent }; |
| 107 | if (session != null) changes.default_session_micros = session; |
| 108 | return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes)); |
| 109 | } |
| 110 | if (intent === "person") { |
| 111 | const username = String(form.get("username") ?? "").trim(); |
| 112 | const amount = microsFromDollars(form.get("amount")); |
| 113 | if (amount != null && Number.isNaN(amount)) return { ok: false as const, intent, error: "Write the amount in dollars, such as 50, or 0 for no budget." }; |
| 114 | return answer("person", workspaceAgents.setPersonBudget(slug, viewer, username, amount)); |
| 115 | } |
| 116 | return { ok: false as const, error: "Unknown request." }; |
| 117 | } |
| 118 | |
| 119 | /** The page's address with some of its query changed. */ |
| 120 | function hrefFor(slug: string, q: { scope: SpendScope; period: string; by: SliceKey }, change: Partial<{ scope: SpendScope; period: string; by: SliceKey }>): string { |
| 121 | const next = { ...q, ...change }; |
| 122 | const params = new URLSearchParams(); |
| 123 | if (next.scope === "me") params.set("scope", "me"); |
| 124 | if (next.period !== "month") params.set("period", next.period); |
| 125 | if (next.by !== "agent") params.set("by", next.by); |
| 126 | const query = params.toString(); |
| 127 | return `/${slug}/-/spend${query ? `?${query}` : ""}`; |
| 128 | } |
| 129 | |
| 130 | export default function SpendPage({ loaderData }: Route.ComponentProps) { |
| 131 | const { slug, me, owner, mayWorkspace, scope, period, by, span, breakdown, usage, budgets, pricing, recs } = loaderData; |
| 132 | const q = { scope, period, by }; |
| 133 | const href = (change: Partial<typeof q>) => hrefFor(slug, q, change); |
| 134 | const label = periodLabel(period).toLowerCase(); |
| 135 | return ( |
| 136 | <div className="mx-auto w-full max-w-[1040px] space-y-8 px-4 py-5 md:px-10 md:py-8"> |
| 137 | <header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> |
| 138 | <div className="min-w-0"> |
| 139 | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Spend</h1> |
| 140 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> |
| 141 | {scope === "workspace" |
| 142 | ? "What the workspace spent, where it went, and the budgets that hold it, from the workspace down to one task." |
| 143 | : "What agents spent working for you: your chats with them and the sessions you started, against your budget."} |
| 144 | </p> |
| 145 | </div> |
| 146 | <div className="flex flex-wrap items-center gap-2"> |
| 147 | {mayWorkspace && ( |
| 148 | <div role="group" aria-label="Whose spend" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> |
| 149 | {(["workspace", "me"] as const).map((key) => ( |
| 150 | <Link |
| 151 | key={key} |
| 152 | to={href({ scope: key, by: "agent" })} |
| 153 | aria-current={scope === key ? "page" : undefined} |
| 154 | className={cn("inline-flex h-7 items-center rounded-md px-3 text-[0.8125rem] transition-colors", scope === key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")} |
| 155 | > |
| 156 | {key === "workspace" ? "Workspace" : "You"} |
| 157 | </Link> |
| 158 | ))} |
| 159 | </div> |
| 160 | )} |
| 161 | <nav aria-label="Period" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> |
| 162 | {SPEND_PERIODS.map((p) => ( |
| 163 | <Link |
| 164 | key={p.key} |
| 165 | to={href({ period: p.key })} |
| 166 | preventScrollReset |
| 167 | aria-current={period === p.key ? "page" : undefined} |
| 168 | className={cn( |
| 169 | "inline-flex h-7 items-center rounded-md px-2.5 text-[0.8125rem] whitespace-nowrap transition-colors", |
| 170 | period === p.key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg", |
| 171 | )} |
| 172 | > |
| 173 | {p.short} |
| 174 | </Link> |
| 175 | ))} |
| 176 | </nav> |
| 177 | </div> |
| 178 | </header> |
| 179 | |
| 180 | {/* The figures at the top. */} |
| 181 | <Suspense |
| 182 | fallback={ |
| 183 | <div className="grid gap-3 sm:grid-cols-3"> |
| 184 | <TileSkeleton /> |
| 185 | <TileSkeleton /> |
| 186 | <TileSkeleton /> |
| 187 | </div> |
| 188 | } |
| 189 | > |
| 190 | <Await resolve={Promise.all([breakdown, usage, budgets])}> |
| 191 | {([spent, used, levels]) => ( |
| 192 | <div className="grid gap-3 sm:grid-cols-3"> |
| 193 | {scope === "workspace" ? ( |
| 194 | <> |
| 195 | <Tile label={`Spent, ${label}`} value={used ? money(used.free ? used.totals.costMicros : used.totals.priceMicros) : "—"} sub={used ? (used.free ? "At cost: g1t charges nothing for now" : "Usage at price, every product") : "Usage couldn't be read"} /> |
| 196 | <Tile label={`Agents, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions` : "The agents service didn't answer"} /> |
| 197 | <Tile |
| 198 | label="Charged, this month" |
| 199 | value={levels.limit?.spentMicros != null ? money(levels.limit.spentMicros) : "—"} |
| 200 | sub={levels.limit ? limitLine(levels.limit.spentMicros ?? 0, levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) : "Billing couldn't be read"} |
| 201 | > |
| 202 | {levels.limit && (levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) != null && ( |
| 203 | <Meter spent={levels.limit.spentMicros ?? 0} cap={levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros} label="Charged of the spend limit" size="sm" className="mt-2.5" /> |
| 204 | )} |
| 205 | </Tile> |
| 206 | </> |
| 207 | ) : ( |
| 208 | <MyTiles spent={spent} levels={levels} me={me} label={label} /> |
| 209 | )} |
| 210 | </div> |
| 211 | )} |
| 212 | </Await> |
| 213 | </Suspense> |
| 214 | |
| 215 | {/* Spend by day. */} |
| 216 | <Section id="by-day" title="By day" aside={`${periodLabel(period)}, UTC`}> |
| 217 | <div className="rounded-xl border border-line bg-surface px-4 pt-4 pb-3"> |
| 218 | <Suspense fallback={<Skeleton className="h-32 w-full" />}> |
| 219 | <Await resolve={Promise.all([breakdown, usage])}> |
| 220 | {([spent, used]) => { |
| 221 | const days = scope === "workspace" && used ? usageByDay(used.days) : (spent?.days ?? null); |
| 222 | return days ? <SpendBars days={daySeries(span.from, span.until, days)} /> : <p className="py-8 text-center text-sm text-muted">Spend by day couldn't be read right now.</p>; |
| 223 | }} |
| 224 | </Await> |
| 225 | </Suspense> |
| 226 | </div> |
| 227 | </Section> |
| 228 | |
| 229 | {/* Where it went. */} |
| 230 | <Section id="where" title="Where it went" aside={scope === "workspace" ? "Agent work by who and where; every product from usage" : "Agent work you asked for"}> |
| 231 | <SliceTabs current={by} scope={scope} href={(key) => href({ by: key })} /> |
| 232 | <Suspense |
| 233 | fallback={ |
| 234 | <SlicePanel> |
| 235 | <RowsSkeleton /> |
| 236 | </SlicePanel> |
| 237 | } |
| 238 | > |
| 239 | <Await resolve={Promise.all([breakdown, usage])}>{([spent, used]) => <Slices by={by} spent={spent} used={used} slug={slug} me={me} scope={scope} />}</Await> |
| 240 | </Suspense> |
| 241 | </Section> |
| 242 | |
| 243 | {/* Spend less, keep quality. */} |
| 244 | <Section |
| 245 | id="savings" |
| 246 | title="Spend less, keep quality" |
| 247 | aside={ |
| 248 | <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1 hover:text-fg"> |
| 249 | Set an agent's effort on its Spend tab <ArrowRight size={11} /> |
| 250 | </Link> |
| 251 | } |
| 252 | > |
| 253 | <Suspense fallback={<Skeleton className="h-28 w-full rounded-xl" />}> |
| 254 | <Await resolve={recs}>{(value) => <Savings recs={value} owner={owner} action={`/${slug}/-/spend`} />}</Await> |
| 255 | </Suspense> |
| 256 | </Section> |
| 257 | |
| 258 | {/* Budgets. */} |
| 259 | <Section id="budgets" title="Budgets" aside="Monthly, UTC. Checked before work starts, widest first"> |
| 260 | <Suspense |
| 261 | fallback={ |
| 262 | <SlicePanel> |
| 263 | <RowsSkeleton rows={5} /> |
| 264 | </SlicePanel> |
| 265 | } |
| 266 | > |
| 267 | <Await resolve={budgets}>{(levels) => <BudgetLadder slug={slug} budgets={levels} owner={owner} me={me} action={`/${slug}/-/spend`} workspaceFigures={mayWorkspace} />}</Await> |
| 268 | </Suspense> |
| 269 | </Section> |
| 270 | |
| 271 | {/* Receipts. */} |
| 272 | <Section id="tasks" title="Costliest tasks" aside="Open one for its receipt"> |
| 273 | <SlicePanel> |
| 274 | <Suspense fallback={<RowsSkeleton />}> |
| 275 | <Await resolve={breakdown}>{(spent) => (spent ? <TaskList slug={slug} sessions={spent.top_sessions} /> : <p className="px-4 py-6 text-center text-sm text-muted">Tasks couldn't be read right now.</p>)}</Await> |
| 276 | </Suspense> |
| 277 | </SlicePanel> |
| 278 | </Section> |
| 279 | |
| 280 | {/* Pricing. */} |
| 281 | <Section |
| 282 | id="pricing" |
| 283 | title="How it's priced" |
| 284 | aside={ |
| 285 | <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1 hover:text-fg"> |
| 286 | Every meter on Usage <ArrowRight size={11} /> |
| 287 | </Link> |
| 288 | } |
| 289 | > |
| 290 | <Suspense fallback={<Skeleton className="h-36 w-full rounded-xl" />}> |
| 291 | <Await resolve={pricing}>{(value) => <PricingCard pricing={value} />}</Await> |
| 292 | </Suspense> |
| 293 | </Section> |
| 294 | |
| 295 | <p className="text-xs text-faint"> |
| 296 | Agent figures count every reply and session at list price, as budgets do. What the workspace is charged, after included usage, credit and any |
| 297 | discount, is on{" "} |
| 298 | <Link to={`/${slug}/-/billing`} className="hover:text-fg hover:underline"> |
| 299 | Billing |
| 300 | </Link> |
| 301 | . |
| 302 | </p> |
| 303 | </div> |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | function countOf(slices: SpendSlice[]): string { |
| 308 | return slices.reduce((n, s) => n + s.count, 0).toLocaleString("en-US"); |
| 309 | } |
| 310 | |
| 311 | function limitLine(spent: number, limit: number | null): string { |
| 312 | if (limit == null) return "No spend limit set"; |
| 313 | return `Of a ${money(limit)} spend limit · ${Math.round((spent / Math.max(1, limit)) * 100)}%`; |
| 314 | } |
| 315 | |
| 316 | /** Your own figures: what agents spent for you, and your budget. */ |
| 317 | function MyTiles({ spent, levels, me, label }: { spent: AgentSpendBreakdown | null; levels: Awaited<Route.ComponentProps["loaderData"]["budgets"]>; me: string; label: string }) { |
| 318 | const people = levels.people; |
| 319 | const mine = people?.people.find((p) => p.username === me) ?? null; |
| 320 | const budget = mine ? mine.monthly_micros : (people?.default_micros ?? null); |
| 321 | const month = mine?.spent_micros ?? null; |
| 322 | const replies = spent?.by_kind.find((s) => s.key === "reply"); |
| 323 | return ( |
| 324 | <> |
| 325 | <Tile label={`Agents for you, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions you asked for` : "The agents service didn't answer"} /> |
| 326 | <Tile label="Your budget, this month" value={budget != null ? money(budget) : "None"} sub={budget != null ? `${month != null ? money(month) : "—"} spent · ${mine?.own ? "set for you" : "the workspace's default"}` : "The workspace's limits apply"}> |
| 327 | {budget != null && month != null && <Meter spent={month} cap={budget} label="Spent of your budget" size="sm" className="mt-2.5" />} |
| 328 | </Tile> |
| 329 | <Tile label={`Your chats with agents, ${label}`} value={replies ? money(replies.micros) : spent ? money(0) : "—"} sub={replies ? `${replies.count.toLocaleString("en-US")} replies` : "Talking costs only what the model answers"} /> |
| 330 | </> |
| 331 | ); |
| 332 | } |
| 333 | |
| 334 | /** The breakdown for the slice chosen. */ |
| 335 | function Slices({ by, spent, used, slug, me, scope }: { by: SliceKey; spent: AgentSpendBreakdown | null; used: UsageReport | null; slug: string; me: string; scope: SpendScope }) { |
| 336 | if (by === "extension") { |
| 337 | return ( |
| 338 | <SlicePanel> |
| 339 | <ComingNote title="Spend by extension">Once extensions can be installed, what each one runs and what agents do in it will show here, priced like everything else.</ComingNote> |
| 340 | </SlicePanel> |
| 341 | ); |
| 342 | } |
| 343 | if (by === "product") { |
| 344 | if (!used) return <Unavailable what="Usage" />; |
| 345 | const slices: SpendSlice[] = used.products.map((p) => ({ key: p.key, label: p.label, micros: p.micros, count: 0 })); |
| 346 | return ( |
| 347 | <SlicePanel foot="Every product, at price, from Usage. Code and Deployments are built in."> |
| 348 | <SliceList slices={slices} empty="Nothing used in this period." href={() => `/${slug}/-/usage`} /> |
| 349 | </SlicePanel> |
| 350 | ); |
| 351 | } |
| 352 | if (!spent) return <Unavailable what="The agents service" />; |
| 353 | const pick: Record<Exclude<SliceKey, "extension" | "product">, { slices: SpendSlice[]; unit: string; foot?: ReactNode; link?: (s: SpendSlice) => string | null }> = { |
| 354 | agent: { |
| 355 | slices: spent.by_agent, |
| 356 | unit: "runs", |
| 357 | foot: "A session's helpers and subagents count on the agent that started it.", |
| 358 | link: (s) => { |
| 359 | const handle = /\(@([^)]+)\)$/.exec(s.label)?.[1]; |
| 360 | return handle ? `/${slug}/-/agents/${handle}/spend` : null; |
| 361 | }, |
| 362 | }, |
| 363 | person: { slices: spent.by_person, unit: "runs", foot: "Who asked. Routines and agent-to-agent work have no asker." }, |
| 364 | channel: { slices: spent.by_channel, unit: "runs", foot: "Where it was asked. Channels you aren't in show only what they cost." }, |
| 365 | model: { slices: spent.by_model, unit: "runs" }, |
| 366 | kind: { slices: spent.by_kind, unit: "" }, |
| 367 | }; |
| 368 | const chosen = pick[by]; |
| 369 | const slices = scope === "me" && by === "person" ? withoutSelf(chosen.slices, me) : chosen.slices; |
| 370 | return ( |
| 371 | <SlicePanel foot={chosen.foot}> |
| 372 | <SliceList slices={slices} unit={chosen.unit} empty="Nothing spent in this period." href={chosen.link} /> |
| 373 | </SlicePanel> |
| 374 | ); |
| 375 | } |
| 376 | |
| 377 | function Unavailable({ what }: { what: string }) { |
| 378 | return ( |
| 379 | <SlicePanel> |
| 380 | <p className="px-4 py-6 text-center text-sm text-muted">{what} didn't answer. Reload in a moment.</p> |
| 381 | </SlicePanel> |
| 382 | ); |
| 383 | } |
| 384 | |