1import { useAtomValue } from "@effect/atom-react";
2import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
3import { useNavigate } from "@tanstack/react-router";
4import { Atom } from "effect/reactivity";
5import { useEffect, useEffectEvent, useRef, useState } from "react";
6
7import { ensureLocalApi } from "../../localApi";
8import { environmentPresentations } from "../../state/presentation";
9import { appAtomRegistry } from "../../rpc/atomRegistry";
10import { serverEnvironment } from "../../state/server";
11import { useAtomCommand } from "../../state/use-atom-command";
12import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
13import { providerClients } from "../settings/providerDriverMeta";
14import { Button } from "../ui/button";
15import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
16import { RefreshIcon } from "../ui/refresh-icon";
17import { useSidebar } from "../ui/sidebar";
18import { accountInitials, UsageLimitsPooled } from "../usage/UsageLimitsPooled";
19import { sidebarUsageProfiles } from "../usage/sidebarUsageProfiles";
20
21const refreshableEnvironmentIdsAtom = Atom.make((get) =>
22 [...get(environmentPresentations.presentationsAtom)]
23 .filter(
24 ([id, presentation]) =>
25 presentation.connection.phase === "connected" &&
26 get(serverEnvironment.refreshProviders.permissionAtom(id)),
27 )
28 .map(([id]) => id),
29);
30
31export function SidebarUsageButton() {
32 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
33 const navigate = useNavigate();
34 const { isMobile, setOpenMobile } = useSidebar();
35 const [open, setOpen] = useState(false);
36 const [contextMenuOpen, setContextMenuOpen] = useState(false);
37 const [hasOpened, setHasOpened] = useState(false);
38 const triggerRef = useRef<HTMLButtonElement>(null);
39 const profiles = sidebarUsageProfiles(presentations);
40
41 const closeSidebar = () => {
42 setOpen(false);
43 if (isMobile) setOpenMobile(false);
44 };
45 const showMenu = async (position: { x: number; y: number }) => {
46 setOpen(false);
47 setContextMenuOpen(true);
48 try {
49 const action = await ensureLocalApi().contextMenu.show(
50 [
51 { id: "limits", label: "Usage limits" },
52 { id: "resets", label: "Usage reset settings…" },
53 { id: "providers", label: "Configure providers…" },
54 ],
55 position,
56 );
57 if (action === null) return;
58 if (action === "limits") {
59 setHasOpened(true);
60 setOpen(true);
61 return;
62 }
63 closeSidebar();
64 if (action === "resets") {
65 await navigate({ to: "/settings/general", hash: "auto-resume-limited-threads" });
66 } else {
67 await navigate({ to: "/settings/providers" });
68 }
69 } finally {
70 setContextMenuOpen(false);
71 }
72 };
73
74 return (
75 <Popover
76 open={open}
77 onOpenChange={(nextOpen, details) => {
78 if (contextMenuOpen || details.reason === "trigger-press") {
79 details.cancel();
80 return;
81 }
82 if (nextOpen) setHasOpened(true);
83 setOpen(nextOpen);
84 }}
85 >
86 <PopoverTrigger
87 render={
88 <button
89 ref={triggerRef}
90 type="button"
91 className="flex min-h-9 w-full flex-col rounded-(--control-radius) px-(--sidebar-row-content-inset) py-1.5 text-sidebar-foreground outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
92 />
93 }
94 openOnHover
95 delay={300}
96 onClick={() => {
97 closeSidebar();
98 void navigate({ to: "/usage" });
99 }}
100 onContextMenu={(event) => {
101 event.preventDefault();
102 void showMenu({ x: event.clientX, y: event.clientY });
103 }}
104 onKeyDown={(event) => {
105 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
106 event.preventDefault();
107 setHasOpened(true);
108 setOpen(true);
109 return;
110 }
111 if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
112 event.preventDefault();
113 const bounds = event.currentTarget.getBoundingClientRect();
114 void showMenu({ x: bounds.left, y: bounds.top });
115 }}
116 aria-label="Usage and costs"
117 aria-description="Press Arrow Down for limits. Open for costs and tokens."
118 >
119 {profiles.length > 0 ? (
120 <span className="grid w-full grid-cols-[repeat(auto-fit,minmax(2.75rem,1fr))] gap-x-2 gap-y-2">
121 {profiles.map(({ account, remainingPercent, window }) => {
122 const providerLabel = providerClients.get(account.driver)?.label ?? account.driver;
123 const label =
124 account.displayName ??
125 (account.email ? accountInitials(account.email) : providerLabel);
126 const badgeLabel =
127 account.displayName ?? (account.email ? accountInitials(account.email) : label);
128 const summary = `${providerLabel}${label === providerLabel ? "" : ` · ${label}`}: ${remainingPercent === null ? "usage unavailable" : `${remainingPercent}% left, ${window?.label}`}`;
129 return (
130 <span
131 key={account.key}
132 role="img"
133 aria-label={summary}
134 className="flex min-w-0 flex-col items-center gap-0.5"
135 >
136 <span className="relative flex size-4.5 items-center justify-center">
137 <svg
138 viewBox="0 0 36 36"
139 className="absolute inset-0 size-full -rotate-90"
140 aria-hidden
141 >
142 <circle
143 cx="18"
144 cy="18"
145 r="16"
146 fill="none"
147 stroke="currentColor"
148 strokeWidth="2"
149 className={
150 remainingPercent !== null && remainingPercent <= 10
151 ? "text-destructive/30"
152 : "text-sidebar-foreground/15"
153 }
154 strokeDasharray={remainingPercent === null ? "2 3" : undefined}
155 />
156 {remainingPercent !== null && remainingPercent > 0 ? (
157 <circle
158 cx="18"
159 cy="18"
160 r="16"
161 pathLength="100"
162 fill="none"
163 stroke="currentColor"
164 strokeWidth="2"
165 strokeLinecap="round"
166 strokeDasharray={`${remainingPercent} 100`}
167 className={remainingPercent <= 10 ? "text-destructive" : "text-primary"}
168 />
169 ) : null}
170 </svg>
171 <ProviderInstanceIcon
172 driverKind={account.driver}
173 displayName={badgeLabel}
174 accentColor={account.accentColor}
175 showBadge={Boolean(account.displayName || account.email)}
176 indicatorBackground="var(--sidebar)"
177 className="size-5 scale-50"
178 iconClassName="size-4"
179 />
180 </span>
181 <span
182 aria-hidden
183 className={`w-full truncate text-center text-3xs tabular-nums ${remainingPercent !== null && remainingPercent <= 10 ? "text-destructive" : ""}`}
184 >
185 {remainingPercent === null ? "—" : `${remainingPercent}%`}
186 </span>
187 </span>
188 );
189 })}
190 </span>
191 ) : (
192 <span className="text-xs text-sidebar-muted-foreground">View costs and tokens</span>
193 )}
194 </PopoverTrigger>
195 <PopoverPopup
196 keepMounted
197 initialFocus={(input) => input === "keyboard"}
198 finalFocus={triggerRef}
199 side="right"
200 align="end"
201 padding="none"
202 className="w-[min(46rem,calc(100vw-2rem))]"
203 >
204 {hasOpened ? (
205 <SidebarUsageDetails
206 open={open}
207 onRedeem={() => setOpen(false)}
208 resetReturnFocus={triggerRef}
209 />
210 ) : null}
211 </PopoverPopup>
212 </Popover>
213 );
214}
215
216function SidebarUsageDetails({
217 open,
218 onRedeem,
219 resetReturnFocus,
220}: {
221 readonly open: boolean;
222 readonly onRedeem: () => void;
223 readonly resetReturnFocus: React.RefObject<HTMLButtonElement | null>;
224}) {
225 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
226 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
227 reportFailure: false,
228 });
229 const [now, setNow] = useState(Date.now);
230 const [refreshing, setRefreshing] = useState(false);
231 const [refreshFailed, setRefreshFailed] = useState(false);
232 const refreshableIds = useAtomValue(refreshableEnvironmentIdsAtom);
233 const refresh = useEffectEvent(async (automatic: boolean) => {
234 try {
235 const results = await Promise.all(
236 refreshableIds
237 .filter((id) =>
238 appAtomRegistry.get(serverEnvironment.refreshProviders.permissionAtom(id)),
239 )
240 .map((environmentId) =>
241 refreshUsageLimits(
242 environmentId,
243 () => refreshProviders({ environmentId, input: { fresh: true } }),
244 automatic,
245 ),
246 ),
247 );
248 setRefreshFailed(results.some((result) => result?._tag === "Failure"));
249 } catch {
250 setRefreshFailed(true);
251 } finally {
252 setNow(Date.now());
253 }
254 });
255
256 useEffect(() => {
257 // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the provider response arrives.
258 if (open && refreshableIds.length > 0) void refresh(true);
259 }, [open, refreshableIds]);
260
261 return (
262 <div className="max-h-[min(40rem,calc(100dvh-3rem))] overflow-y-auto p-4">
263 <div className="mb-4 flex items-center justify-between gap-3">
264 <PopoverTitle>Usage limits</PopoverTitle>
265 <Button
266 variant="ghost"
267 size="icon-sm"
268 aria-label="Refresh limits"
269 disabled={refreshing || refreshableIds.length === 0}
270 onClick={() => {
271 setRefreshing(true);
272 setRefreshFailed(false);
273 void refresh(false).finally(() => setRefreshing(false));
274 }}
275 >
276 <RefreshIcon refreshing={refreshing} />
277 </Button>
278 </div>
279 {refreshFailed ? (
280 <p role="status" className="mb-3 text-xs text-destructive">
281 Could not refresh limits. Try again.
282 </p>
283 ) : null}
284 <UsageLimitsPooled
285 presentations={presentations}
286 now={now}
287 onRedeem={onRedeem}
288 resetReturnFocus={resetReturnFocus}
289 />
290 </div>
291 );
292}