Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
SettingsIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { memo, useCallback } from "react";
import { memo, useCallback, useId } from "react";
import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";

import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
Expand All @@ -30,6 +30,7 @@ import {
useSidebar,
} from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { UsageLimitsTooltip } from "../usage/UsageLimitsTooltip";
import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
Expand Down Expand Up @@ -110,22 +111,30 @@ function SidebarUtilityItem({
icon,
label,
onClick,
renderTooltip,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
renderTooltip?: (id: string) => ReactNode;
}) {
const tooltipId = useId();
return (
<SidebarMenuItem className="shrink-0">
<Tooltip>
<TooltipTrigger
render={
<SidebarMenuButton aria-label={label} onClick={onClick} size="icon">
<SidebarMenuButton
aria-label={label}
aria-describedby={renderTooltip ? tooltipId : undefined}
onClick={onClick}
size="icon"
>
{icon}
</SidebarMenuButton>
}
/>
<TooltipPopup side="top">{label}</TooltipPopup>
{renderTooltip ? renderTooltip(tooltipId) : <TooltipPopup side="top">{label}</TooltipPopup>}
</Tooltip>
</SidebarMenuItem>
);
Expand Down Expand Up @@ -213,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
icon={<ChartNoAxesColumnIcon />}
label="Usage"
onClick={handleUsageClick}
renderTooltip={(id) => <UsageLimitsTooltip id={id} />}
/>
</>
)}
Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ServerProvider,
ServerProviderResetCredits,
ServerProviderUsageWindow,
UsageProviderKind,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import {
Expand Down Expand Up @@ -36,21 +35,14 @@ import {
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { UsageLimitsPooled } from "./UsageLimitsPooled";
import { PROVIDER_PRESENTATION } from "./usageProviders";
import { barColor } from "./usageProviders";

const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
ahead: { label: "Ahead of pace: spending faster than the window elapses", icon: TrendingUpIcon },
on: { label: "On pace with the window", icon: GaugeIcon },
under: { label: "Under pace: headroom left for the rest of the window", icon: TrendingDownIcon },
};

/** The series colour the cost chart uses for this driver, so the two views read as one. */
export function barColor(driver: ServerProvider["driver"]): string {
const kind: UsageProviderKind | undefined =
driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined;
return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)";
}

/** Pace as a glyph with the words on hover. */
export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
const Icon = PACE[pace].icon;
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,8 @@ import { getDriverOption } from "../settings/providerDriverMeta";
import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import {
PaceIcon,
ResetCreditDialog,
barColor,
resetCreditsSummary,
useResetCredit,
} from "./UsageLimits";
import { PaceIcon, ResetCreditDialog, resetCreditsSummary, useResetCredit } from "./UsageLimits";
import { barColor } from "./usageProviders";

/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
function accountInitials(email: string): string {
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/usage/UsageLimitsTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useAtomValue } from "@effect/atom-react";
import {
collectLimitAccounts,
collectLimitNotices,
collectLimitPools,
formatDuration,
} from "@t3tools/shared/usageLimits";
import { useState } from "react";

import { environmentPresentations } from "../../state/presentation";
import { getDriverOption } from "../settings/providerDriverMeta";
import { TooltipPopup } from "../ui/tooltip";
import { barColor } from "./usageProviders";

/** Reads the existing config snapshots only while the tooltip content is mounted. */
function UsageLimitsSummary() {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const [now] = useState(() => Date.now());
const pools = collectLimitPools(collectLimitAccounts(presentations), now);
const notices = collectLimitNotices(presentations);

return (
<div className="flex max-h-[min(28rem,60vh)] flex-col gap-4 overflow-y-auto p-2 text-xs">
<h2 className="text-sm font-medium">Usage limits</h2>
{pools.map((pool) => (
<section key={pool.driver} className="flex flex-col gap-2">
<h3 className="flex items-baseline justify-between gap-3 font-medium">
<span>{getDriverOption(pool.driver)?.label ?? String(pool.driver)}</span>
{pool.accounts.length > 1 ? (
<span className="font-normal text-muted-foreground">
{pool.accounts.length} accounts
</span>
) : null}
</h3>
{pool.windows.map((window) => {
const nextRefill = window.resets.find((reset) => reset.restoresPercent > 0);
return (
<div key={`${window.kind}:${window.id}`} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 break-words">{window.label}</span>
<span className="shrink-0 font-medium tabular-nums">
{window.remainingPercent}% left
</span>
</div>
<div aria-hidden className="h-1 overflow-hidden rounded-sm bg-muted">
<div
className="h-full rounded-sm"
style={{
width: `${window.remainingPercent}%`,
backgroundColor: barColor(pool.driver),
}}
/>
</div>
{nextRefill ? (
<span className="text-muted-foreground tabular-nums">
+{nextRefill.restoresPercent}%{" "}
{nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
</span>
) : null}
</div>
);
})}
</section>
))}
{pools.length === 0 && notices.length === 0 ? <p>No subscription limits reported.</p> : null}
{notices.length > 0 ? (
<ul className="flex flex-col gap-1 text-muted-foreground">
{notices.map((notice) => (
<li key={notice} className="break-words">
{notice}
</li>
))}
</ul>
) : null}
</div>
);
}

export function UsageLimitsTooltip({ id }: { readonly id: string }) {
return (
<TooltipPopup
id={id}
role="tooltip"
side="top"
align="start"
className="pointer-events-auto w-72 max-w-[calc(100vw-2rem)] text-wrap"
>
<UsageLimitsSummary />
</TooltipPopup>
);
}
9 changes: 8 additions & 1 deletion apps/web/src/components/usage/usageProviders.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { UsageProviderKind } from "@t3tools/contracts";
import type { ServerProvider, UsageProviderKind } from "@t3tools/contracts";

import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons";

Expand Down Expand Up @@ -32,6 +32,13 @@ export const PROVIDER_PRESENTATION = {
},
} satisfies Record<UsageProviderKind, UsageProviderPresentation>;

/** The series colour the cost chart uses for this driver, so the two views read as one. */
export function barColor(driver: ServerProvider["driver"]): string {
const kind: UsageProviderKind | undefined =
driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined;
return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)";
}

/** Stable provider reading order across charts, summaries, tables, and hover rows. */
export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];

Expand Down
Loading