Skip to content

g1t/apps/web/app/components/token-usage.tsx

206 lines8,154 bytesCodeBlame
1import { useState } from "react";
2import { Link } from "react-router";
3
4import { cn } from "../lib/cn";
5import { usd } from "../lib/mission-control";
6import { Hint } from "./ui/hint";
7import {
8 HEAT_LEVELS,
9 type MixPart,
10 type TokenUsageView,
11 bestDay,
12 cacheShare,
13 compactTokens,
14 heatLevel,
15 shortDay,
16 tokenMix,
17} from "../lib/token-usage";
18
19/**
20 * The token mix's colours, in its stacking order. Checked against the dark
21 * surface (lightness band, chroma, contrast, and neighbours apart for every
22 * kind of colour vision); change them only by re-running that check.
23 */
24const MIX_COLOR: Record<MixPart["key"], string> = {
25 input: "#9085e9",
26 cacheRead: "#199e70",
27 cacheWrite: "#3987e5",
28 output: "#d55181",
29};
30
31/** The daily grid's single hue, stronger with more tokens. */
32const HEAT_SHADE = ["var(--color-line)", ...Array.from({ length: HEAT_LEVELS }, (_, i) => `color-mix(in oklab, var(--color-accent) ${[28, 48, 72, 100][i]}%, var(--color-surface))`)];
33
34type Scope = "workspace" | "mine";
35
36/**
37 * Model tokens over the last weeks, for the workspace or for you: what was
38 * used and what it cost, how many days, how much came from the cache, each
39 * day's intensity, and the mix of input, cache and output.
40 */
41export function TokenUsagePanel({
42 workspace,
43 mine,
44 usageHref,
45}: {
46 workspace: TokenUsageView | null;
47 mine: TokenUsageView | null;
48 usageHref: string | null;
49}) {
50 const [scope, setScope] = useState<Scope>("workspace");
51 const usage = scope === "mine" ? mine : workspace;
52 return (
53 <section aria-labelledby="token-usage" className="rounded-xl border border-line bg-surface p-5">
54 <div className="flex items-center justify-between gap-3">
55 <h2 id="token-usage" className="text-base font-semibold tracking-tight">
56 Usage
57 </h2>
58 <div role="tablist" aria-label="Whose usage" className="flex rounded-md border border-line p-0.5 text-xs">
59 {(["workspace", "mine"] as const).map((option) => (
60 <button
61 key={option}
62 type="button"
63 role="tab"
64 aria-selected={scope === option}
65 disabled={option === "mine" && !mine}
66 onClick={() => setScope(option)}
67 className={cn(
68 "rounded px-2 py-0.5 transition-colors disabled:opacity-40",
69 scope === option ? "bg-raised text-fg" : "text-muted hover:text-fg",
70 )}
71 >
72 {option === "workspace" ? "Workspace" : "You"}
73 </button>
74 ))}
75 </div>
76 </div>
77 {usage ? <Figures usage={usage} usageHref={usageHref} /> : <p className="mt-4 text-sm text-muted">Usage could not be loaded.</p>}
78 </section>
79 );
80}
81
82function Figures({ usage, usageHref }: { usage: TokenUsageView; usageHref: string | null }) {
83 const share = cacheShare(usage);
84 const weeks = Math.round(usage.days / 7);
85 if (usage.totalTokens === 0) {
86 return (
87 <p className="mt-4 text-sm text-muted">
88 No model tokens in the last {weeks} weeks{usage.person ? " on work you asked for" : ""}. Agents' runs show here as they work.
89 </p>
90 );
91 }
92 return (
93 <>
94 <dl className="mt-4 grid grid-cols-2 gap-2">
95 <Tile label="Tokens" value={compactTokens(usage.totalTokens)} />
96 <Tile label="Cost" value={usd(usage.costMicros / 1e6)} title="What these runs cost, whoever paid" />
97 <Tile label="Active days" value={String(usage.activeDays)} hint={`of ${usage.days}`} />
98 <Tile label="From cache" value={share == null ? "—" : `${Math.round(share * 100)}%`} title="Prompt tokens read from the model's cache" />
99 </dl>
100 <DailyGrid byDay={usage.byDay} />
101 <Mix usage={usage} />
102 {usageHref && (
103 <Link to={usageHref} className="mt-4 block border-t border-line pt-3 text-xs text-muted hover:text-fg">
104 Every run, by repository and model
105 </Link>
106 )}
107 </>
108 );
109}
110
111function Tile({ label, value, hint, title }: { label: string; value: string; hint?: string; title?: string }) {
112 return (
113 <Hint label={title}>
114 <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5">
115 <dt className="text-[0.6875rem] text-muted">{label}</dt>
116 <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums">
117 {value}
118 {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>}
119 </dd>
120 </div>
121 </Hint>
122 );
123}
124
125/**
126 * Each day as a cell, two rows, oldest first; shaded by quartile of the
127 * busiest day. Hover or focus a day to read it.
128 */
129function DailyGrid({ byDay }: { byDay: TokenUsageView["byDay"] }) {
130 const best = bestDay(byDay);
131 const busiest = best?.tokens ?? 0;
132 const [shown, setShown] = useState<{ day: string; tokens: number } | null>(null);
133 const columns = Math.ceil(byDay.length / 2);
134 const readout = shown ?? best;
135 return (
136 <div className="mt-5">
137 <div className="flex items-baseline justify-between gap-2">
138 <h3 className="text-xs font-medium text-fg-soft">Each day</h3>
139 <p className="text-[0.6875rem] text-muted tabular-nums" aria-live="polite">
140 {readout ? `${shown ? "" : "Busiest: "}${shortDay(readout.day)} · ${compactTokens(readout.tokens)}` : ""}
141 </p>
142 </div>
143 <div
144 className="mt-2 grid gap-[3px]"
145 style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
146 onMouseLeave={() => setShown(null)}
147 >
148 {byDay.map((entry) => (
149 <span
150 key={entry.day}
151 role="img"
152 tabIndex={0}
153 aria-label={`${shortDay(entry.day)}: ${compactTokens(entry.tokens)} tokens`}
154 onMouseEnter={() => setShown(entry)}
155 onFocus={() => setShown(entry)}
156 onBlur={() => setShown(null)}
157 className="aspect-square rounded-[3px] outline-offset-1 hover:ring-1 hover:ring-fg-soft focus-visible:outline-2 focus-visible:outline-accent"
158 style={{ background: HEAT_SHADE[heatLevel(entry.tokens, busiest)] }}
159 />
160 ))}
161 </div>
162 <div className="mt-1.5 flex items-center justify-between text-[0.6875rem] text-faint tabular-nums">
163 <span>{byDay[0] ? shortDay(byDay[0].day) : ""}</span>
164 <span className="flex items-center gap-1" aria-hidden>
165 Less
166 {HEAT_SHADE.map((shade) => (
167 <span key={shade} className="size-2 rounded-[2px]" style={{ background: shade }} />
168 ))}
169 More
170 </span>
171 <span>{byDay.length ? shortDay(byDay[byDay.length - 1].day) : ""}</span>
172 </div>
173 </div>
174 );
175}
176
177/** Input, cache reads, cache writes and output as one bar, each part labelled. */
178function Mix({ usage }: { usage: TokenUsageView }) {
179 const parts = tokenMix(usage).filter((part) => part.tokens > 0);
180 return (
181 <div className="mt-5">
182 <h3 className="text-xs font-medium text-fg-soft">Token mix</h3>
183 <div className="mt-2 flex h-2.5 gap-[2px] overflow-hidden rounded" role="img" aria-label={parts.map((p) => `${p.label} ${Math.round(p.share * 100)}%`).join(", ")}>
184 {parts.map((part) => (
185 <Hint key={part.key} label={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`}>
186 <span
187 className="h-full first:rounded-l last:rounded-r"
188 style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }}
189 />
190 </Hint>
191 ))}
192 </div>
193 <ul className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[0.6875rem]">
194 {tokenMix(usage).map((part) => (
195 <li key={part.key} className="flex min-w-0 items-center gap-1.5">
196 <span className="size-2 shrink-0 rounded-[2px]" style={{ background: MIX_COLOR[part.key] }} />
197 <span className="truncate text-muted">{part.label}</span>
198 <span className="ml-auto text-fg-soft tabular-nums">
199 {compactTokens(part.tokens)} <span className="text-faint">{Math.round(part.share * 100)}%</span>
200 </span>
201 </li>
202 ))}
203 </ul>
204 </div>
205 );
206}