| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | import { Atom } from "effect/reactivity"; |
| 5 | import { useEffect, useEffectEvent, useRef, useState } from "react"; |
| 6 | |
| 7 | import { ensureLocalApi } from "../../localApi"; |
| 8 | import { environmentPresentations } from "../../state/presentation"; |
| 9 | import { appAtomRegistry } from "../../rpc/atomRegistry"; |
| 10 | import { serverEnvironment } from "../../state/server"; |
| 11 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 12 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 13 | import { providerClients } from "../settings/providerDriverMeta"; |
| 14 | import { Button } from "../ui/button"; |
| 15 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; |
| 16 | import { RefreshIcon } from "../ui/refresh-icon"; |
| 17 | import { useSidebar } from "../ui/sidebar"; |
| 18 | import { accountInitials, UsageLimitsPooled } from "../usage/UsageLimitsPooled"; |
| 19 | import { sidebarUsageProfiles } from "../usage/sidebarUsageProfiles"; |
| 20 | |
| 21 | const 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 | |
| 31 | export 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 | |
| 216 | function 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 | } |