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