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 | */ | |
| 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"; | |
| 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. | 18 | import { 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. | 19 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 20 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 21 | import { Hint } from "./ui/hint"; | |
| 22 | import { Input } from "./ui/input"; | |
| 23 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 24 | import { Skeleton } from "./ui/skeleton"; | |
| 25 | import { TimeAgo } from "./ui"; | |
| 26 | import { dollarsField } from "../lib/agent-form"; | |
| 27 | import { cn } from "../lib/cn"; | |
| 28 | import 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. | 29 | import { 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. | 30 | import { 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. */ | |
| 34 | export 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 | ||
| 45 | export 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 | */ | |
| 59 | export 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. */ | |
| 95 | export 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. */ | |
| 110 | export type SliceKey = "agent" | "person" | "channel" | "model" | "kind" | "product" | "extension"; | |
| 111 | ||
| 112 | export 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. */ | |
| 123 | export 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. */ | |
| 149 | export 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 | ||
| 158 | export { SliceList }; | |
| 159 | ||
| 160 | /** Something not built yet, said plainly where it will be. */ | |
| 161 | export 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%. */ | |
| 176 | function 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 | ||
| 224 | 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"; | |
| 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 | */ | |
| 232 | export 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'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 | ||
| 299 | function 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. */ | |
| 310 | function 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. */ | |
| 342 | function 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. */ | |
| 373 | function 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'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. */ | |
| 421 | function 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. */ | |
| 468 | export 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. */ | |
| 473 | export 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. */ | |
| 500 | export 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'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 | ||
| 548 | const SCOPE_KEY = "g1t_spend_pill"; | |
| 549 | ||
| 550 | function 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 | */ |
| 566 | export 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 | ||
| 620 | function 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'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'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 | ||
| 719 | function 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 | } |