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

203 lines8,064 bytesCodeBlame

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.

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