Skip to content

Commit 491bcb2

Browse files
feat(web): thread header shows subscription usage meters
Subscription limits are visible on Usage → Limits and through /usage-limits, but nothing shows them at a glance while working. An opt-in Appearance setting, "Usage meters", adds a compact meter per provider that reports limits to the thread header, as bars, the percent left, or both. Meters pool accounts across environments and usage hubs from the same snapshot Usage → Limits reads. Selecting one lists each account's windows in the composer's compact limit rows, read-only, with a link to Usage → Limits, where accounts and resets stay. A reading whose reset has passed shows as unknown instead of a stale number; the header re-renders once at the next reset rather than ticking a clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent de72286 commit 491bcb2

11 files changed

Lines changed: 366 additions & 6 deletions

File tree

‎apps/desktop/src/settings/DesktopClientSettings.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const clientSettings: ClientSettings = {
3434
confirmThreadDelete: false,
3535
confirmThreadUnpin: false,
3636
contextWindowMeterEnabled: false,
37+
usageMeterMode: "none",
3738
composerCollapseOnScroll: true,
3839
dismissedProviderUpdateNotificationKeys: [],
3940
diffFilesCollapsed: true,

‎apps/web/src/components/chat/ChatHeader.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
WorkspaceBreadcrumbText,
3838
} from "../WorkspaceBreadcrumb";
3939
import { cn } from "~/lib/utils";
40+
import { HeaderUsageMeters } from "./HeaderUsageMeters";
4041

4142
interface ChatHeaderProps {
4243
activeThreadEnvironmentId: EnvironmentId;
@@ -254,7 +255,7 @@ export const ChatHeader = memo(function ChatHeader({
254255
return (
255256
<div
256257
className={cn(
257-
"flex min-w-0 flex-1 items-center gap-2 sm:gap-3",
258+
"@container/header flex min-w-0 flex-1 items-center gap-2 sm:gap-3",
258259
rightPanelOpen ? "pr-10" : "pr-24",
259260
)}
260261
onContextMenu={handleHeaderContextMenu}
@@ -355,6 +356,7 @@ export const ChatHeader = memo(function ChatHeader({
355356
)}
356357
</WorkspaceBreadcrumbItem>
357358
</WorkspaceBreadcrumb>
359+
<HeaderUsageMeters />
358360
</div>
359361
);
360362
});

‎apps/web/src/components/chat/ComposerUsageLimits.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ function accountLabel(account: UsageLimitsReport["accounts"][number]): string {
2323
: driver;
2424
}
2525

26-
function AccountSummary({ account }: { readonly account: UsageLimitsReport["accounts"][number] }) {
27-
const label = accountLabel(account);
26+
/** An account's label, blurred when it is an email, then its plan. */
27+
export function AccountSummary({
28+
label,
29+
plan,
30+
}: {
31+
readonly label: string;
32+
readonly plan?: string | undefined;
33+
}) {
2834
return (
2935
<>
3036
{label.includes("@") ? (
@@ -39,7 +45,7 @@ function AccountSummary({ account }: { readonly account: UsageLimitsReport["acco
3945
) : (
4046
label
4147
)}
42-
{account.plan ? ` · ${account.plan}` : null}
48+
{plan ? `${label ? " · " : ""}${plan}` : null}
4349
</>
4450
);
4551
}
@@ -54,7 +60,7 @@ export function usageLimitsBannerItem(
5460
const [first] = report.accounts;
5561
const single = report.accounts.length === 1 && first ? first : null;
5662
const summary = single ? (
57-
<AccountSummary account={single} />
63+
<AccountSummary label={accountLabel(single)} plan={single.plan} />
5864
) : (
5965
`${report.accounts.length} accounts`
6066
);
@@ -92,7 +98,7 @@ function UsageLimitsBannerBody({
9298
<div key={account.id} className="flex min-w-0 flex-col gap-1">
9399
{report.accounts.length > 1 ? (
94100
<span className="truncate text-xs text-muted-foreground">
95-
<AccountSummary account={account} />
101+
<AccountSummary label={accountLabel(account)} plan={account.plan} />
96102
</span>
97103
) : null}
98104
{notice ? (
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import {
3+
collectLimitAccounts,
4+
collectLimitPools,
5+
displayLimitWindows,
6+
isLimitPoolWindowExpired,
7+
type LimitPool,
8+
} from "@t3tools/shared/usageLimits";
9+
import { useNavigate } from "@tanstack/react-router";
10+
import { ArrowRightIcon } from "lucide-react";
11+
import { useEffect, useState } from "react";
12+
13+
import { useClientSettings } from "../../hooks/useSettings";
14+
import { cn } from "../../lib/utils";
15+
import { environmentPresentations } from "../../state/presentation";
16+
import { getDriverOption } from "../settings/providerDriverMeta";
17+
import { Button } from "../ui/button";
18+
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
19+
import { LimitWindows, barColor } from "../usage/UsageLimits";
20+
import { readUsagePagePreferences, saveUsagePagePreferences } from "../usage/usagePagePreferences";
21+
import { AccountSummary } from "./ComposerUsageLimits";
22+
import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
23+
24+
/** The first displayed window on top, the next as a thinner bar under it. */
25+
function meterWindows(pool: LimitPool) {
26+
return displayLimitWindows(pool).slice(0, 2);
27+
}
28+
29+
/**
30+
* Subscription quota for every connected provider, pooled across environments
31+
* like the usage page. Each meter shows the pool; selecting one lists each
32+
* account's windows in the composer's compact rows. Accounts and resets are
33+
* managed on the usage page. `now` advances when a popover opens and once when
34+
* the next shown reading expires, rather than ticking: a live clock would
35+
* repaint the header for no decision-changing gain. Hidden when the header is
36+
* too narrow to spare the room.
37+
*/
38+
export function HeaderUsageMeters() {
39+
const mode = useClientSettings((settings) => settings.usageMeterMode);
40+
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
41+
const [now, setNow] = useState(() => Date.now());
42+
const pools = mode === "none" ? [] : collectLimitPools(collectLimitAccounts(presentations), now);
43+
const nextExpiry = Math.min(
44+
...pools.flatMap((pool) =>
45+
meterWindows(pool).flatMap((window) => {
46+
const at = window.resets[0]?.at;
47+
return at !== undefined && at > now ? [at] : [];
48+
}),
49+
),
50+
);
51+
// setTimeout overflows past about 24 days, so a later expiry wakes early and reschedules.
52+
const wakeAt = Number.isFinite(nextExpiry) ? Math.min(nextExpiry, now + 2 ** 31 - 1) : null;
53+
useEffect(() => {
54+
if (wakeAt === null) return;
55+
const timer = setTimeout(() => setNow(Date.now()), wakeAt - Date.now());
56+
return () => clearTimeout(timer);
57+
}, [wakeAt]);
58+
if (pools.length === 0) return null;
59+
return (
60+
<div className="hidden shrink-0 items-center gap-1 @2xl/header:flex">
61+
{pools.map((pool) => (
62+
<ProviderMeter
63+
key={pool.driver}
64+
pool={pool}
65+
now={now}
66+
showBars={mode !== "percent"}
67+
showPercent={mode !== "bars"}
68+
onOpen={() => setNow(Date.now())}
69+
/>
70+
))}
71+
</div>
72+
);
73+
}
74+
75+
function ProviderMeter({
76+
pool,
77+
now,
78+
showBars,
79+
showPercent,
80+
onOpen,
81+
}: {
82+
readonly pool: LimitPool;
83+
readonly now: number;
84+
readonly showBars: boolean;
85+
readonly showPercent: boolean;
86+
readonly onOpen: () => void;
87+
}) {
88+
const navigate = useNavigate();
89+
const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);
90+
const color = barColor(pool.driver);
91+
const poolWindows = displayLimitWindows(pool);
92+
const shown = meterWindows(pool).map((window) => ({
93+
window,
94+
expired: isLimitPoolWindowExpired(window, now),
95+
}));
96+
const [primary] = shown;
97+
const summary = shown
98+
.map(
99+
({ window, expired }) =>
100+
`${window.label} ${expired ? "unknown" : `${window.remainingPercent}% left`}`,
101+
)
102+
.join(", ");
103+
// Each account's windows in the pool's display order; Cursor's combined
104+
// window can drop out, so an account may have none left to show.
105+
const accounts = pool.accounts.flatMap((account) => {
106+
const windows = poolWindows.flatMap((window) =>
107+
window.members
108+
.filter((member) => member.account.key === account.key)
109+
.map((member) => member.window),
110+
);
111+
return windows.length > 0 ? [{ account, windows }] : [];
112+
});
113+
return (
114+
<Popover
115+
onOpenChange={(open) => {
116+
if (open) onOpen();
117+
}}
118+
>
119+
<PopoverTrigger
120+
render={
121+
<Button variant="ghost-muted" size="xs" aria-label={`${label} usage: ${summary}`} />
122+
}
123+
>
124+
<ProviderInstanceIcon
125+
driverKind={pool.driver}
126+
displayName={label}
127+
className="size-3.5"
128+
iconClassName="size-3.5 text-foreground/80"
129+
/>
130+
{showBars ? (
131+
<span className="flex w-10 flex-col gap-0.5">
132+
{shown.map(({ window, expired }, index) => (
133+
<MeterBar
134+
key={`${window.kind}:${window.id}`}
135+
remainingPercent={expired ? null : window.remainingPercent}
136+
color={color}
137+
thick={index === 0}
138+
/>
139+
))}
140+
</span>
141+
) : null}
142+
{showPercent && primary ? (
143+
<span className="min-w-6 text-2xs font-medium text-muted-foreground tabular-nums">
144+
{primary.expired ? "?" : `${primary.window.remainingPercent}%`}
145+
</span>
146+
) : null}
147+
</PopoverTrigger>
148+
<PopoverPopup
149+
width="lg"
150+
side="bottom"
151+
align="end"
152+
aria-label={`${label} usage limits`}
153+
// The meters hide when the header narrows; an open popup must not outlive them.
154+
className="in-data-anchor-hidden:hidden"
155+
>
156+
<div className="flex flex-col gap-3">
157+
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
158+
<ProviderInstanceIcon
159+
driverKind={pool.driver}
160+
displayName={label}
161+
className="size-4"
162+
iconClassName="size-4 text-foreground/80"
163+
/>
164+
{label}
165+
</span>
166+
{accounts.map(({ account, windows }) => (
167+
<div key={account.key} className="flex min-w-0 flex-col gap-1">
168+
{accounts.length > 1 ? (
169+
<span className="truncate text-xs text-muted-foreground">
170+
<AccountSummary
171+
label={account.displayName ?? account.email ?? ""}
172+
plan={account.plan}
173+
/>
174+
</span>
175+
) : null}
176+
<LimitWindows compact driver={pool.driver} windows={windows} now={now} />
177+
</div>
178+
))}
179+
<Button
180+
variant="ghost-muted"
181+
size="xs"
182+
className="self-end"
183+
onClick={() => {
184+
// The page reopens its last metric; arriving from here should show limits.
185+
saveUsagePagePreferences({ ...readUsagePagePreferences(), metric: "limits" });
186+
void navigate({ to: "/usage" });
187+
}}
188+
>
189+
Usage dashboard
190+
<ArrowRightIcon className="size-3.5" aria-hidden="true" />
191+
</Button>
192+
</div>
193+
</PopoverPopup>
194+
</Popover>
195+
);
196+
}
197+
198+
/**
199+
* Quota left as a depleting fill: full when fresh, empty when the window is
200+
* spent. An expired reading has no fill and a fainter track, so it reads as
201+
* unknown rather than spent.
202+
*/
203+
function MeterBar({
204+
remainingPercent,
205+
color,
206+
thick,
207+
}: {
208+
readonly remainingPercent: number | null;
209+
readonly color: string;
210+
readonly thick: boolean;
211+
}) {
212+
return (
213+
<span
214+
aria-hidden
215+
className={cn("relative block overflow-hidden rounded-full", thick ? "h-1.5" : "h-1")}
216+
// A faint tint of the fill keeps the full length readable when little quota remains.
217+
style={{
218+
backgroundColor: `color-mix(in srgb, ${color} ${remainingPercent === null ? 8 : 20}%, transparent)`,
219+
}}
220+
>
221+
{remainingPercent === null ? null : (
222+
<span
223+
className="absolute inset-y-0 left-0 rounded-full"
224+
style={{ width: `${remainingPercent}%`, backgroundColor: color }}
225+
/>
226+
)}
227+
</span>
228+
);
229+
}

0 commit comments

Comments
 (0)