diff --git a/.github/pr-assets/usage/chat-usage-widget.png b/.github/pr-assets/usage/chat-usage-widget.png new file mode 100644 index 00000000..5b17e921 Binary files /dev/null and b/.github/pr-assets/usage/chat-usage-widget.png differ diff --git a/.github/pr-assets/usage/settings-usage.png b/.github/pr-assets/usage/settings-usage.png new file mode 100644 index 00000000..5b17e921 Binary files /dev/null and b/.github/pr-assets/usage/settings-usage.png differ diff --git a/apps/server/src/provider/usage/modules/claudeUsageModule.ts b/apps/server/src/provider/usage/modules/claudeUsageModule.ts index f7b684f3..7468cc39 100644 --- a/apps/server/src/provider/usage/modules/claudeUsageModule.ts +++ b/apps/server/src/provider/usage/modules/claudeUsageModule.ts @@ -42,6 +42,38 @@ function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function normalizeClaudePlanName(value: string | null | undefined): string | null { + const raw = value?.trim(); + if (!raw) { + return null; + } + + const normalized = raw.toLowerCase().replace(/[\s_-]+/g, ""); + switch (normalized) { + case "pro": + case "claudepro": + case "defaultclaudeai": + return "pro"; + case "max": + case "max5": + case "max20": + case "maxplan": + case "claudemax": + return "max"; + case "team": + case "claudeteam": + return "team"; + case "enterprise": + case "claudeenterprise": + return "enterprise"; + case "free": + case "claudefree": + return "free"; + default: + return raw; + } +} + function toUsageState(status: "ready" | "warning" | "error"): ProviderUsageState { switch (status) { case "ready": @@ -58,20 +90,23 @@ function parseAuthStatusJson(stdout: string): { readonly planName: string | null; readonly loginMethod: string | null; readonly email: string | null; + readonly org: string | null; } { let parsed: unknown; try { parsed = JSON.parse(stdout); } catch { - return { planName: null, loginMethod: null, email: null }; + return { planName: null, loginMethod: null, email: null, org: null }; } - const planName = - findStringByKeys(parsed, ["plan", "planType", "subscriptionType", "subscription", "tier"]) ?? - null; + const planName = normalizeClaudePlanName( + findStringByKeys(parsed, ["plan", "planType", "subscriptionType", "subscription", "tier"]), + ); const loginMethod = findStringByKeys(parsed, ["authMethod", "auth_method"]) ?? null; const email = findStringByKeys(parsed, ["email"]) ?? null; - return { planName, loginMethod, email }; + const org = + findStringByKeys(parsed, ["orgName", "organization", "organizationName", "org"]) ?? null; + return { planName, loginMethod, email, org }; } function parseClaudeOAuthCredentials(stdout: string): { @@ -89,10 +124,11 @@ function parseClaudeOAuthCredentials(stdout: string): { const oauth = asRecord(root?.claudeAiOauth) ?? root; return { accessToken: asString(oauth?.accessToken) ?? null, - planName: + planName: normalizeClaudePlanName( asString(oauth?.subscriptionType) ?? - findStringByKeys(parsed, ["subscriptionType", "subscription_type"]) ?? - null, + asString(oauth?.rateLimitTier) ?? + findStringByKeys(parsed, ["subscriptionType", "subscription_type", "rateLimitTier"]), + ), }; } @@ -266,7 +302,7 @@ export const makeClaudeUsageModule = Effect.gen(function* () { planName: parsedJson.planName, loginMethod: parsedJson.loginMethod, email: parsedJson.email, - org: null, + org: parsedJson.org, }, windows: [], raw: result.stdout || result.stderr || null, @@ -316,15 +352,24 @@ export const makeClaudeUsageModule = Effect.gen(function* () { } if (event.type === "account.updated") { - const planName = findStringByKeys(event.payload.account, [ - "subscriptionType", - "planType", - "plan", - "tier", - ]); + const planName = normalizeClaudePlanName( + findStringByKeys(event.payload.account, [ + "subscriptionType", + "planType", + "plan", + "tier", + "rateLimitTier", + ]), + ); const loginMethod = findStringByKeys(event.payload.account, ["authMethod", "auth_method"]); const email = findStringByKeys(event.payload.account, ["email"]); - if (!planName && !loginMethod && !email) { + const org = findStringByKeys(event.payload.account, [ + "orgName", + "organization", + "organizationName", + "org", + ]); + if (!planName && !loginMethod && !email && !org) { return undefined; } @@ -337,6 +382,7 @@ export const makeClaudeUsageModule = Effect.gen(function* () { ...(planName ? { planName } : {}), ...(loginMethod ? { loginMethod } : {}), ...(email ? { email } : {}), + ...(org ? { org } : {}), }, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c03cdd6..c098c936 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5034,12 +5034,6 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - setProviderUsagePanelOpen(false)} - /> setThreadError(activeThread.id, null)} @@ -5123,9 +5117,18 @@ export default function ChatView({ threadId }: ChatViewProps) { className="mx-auto w-full min-w-0 max-w-208" data-chat-composer-form="true" > +
+ setProviderUsagePanelOpen(false)} + className="pointer-events-auto max-w-[32rem]" + /> +
void; + className?: string; }) { - if (!visible) { - return null; - } - const metadata = PROVIDER_USAGE_METADATA[provider]; - const providerLabel = PROVIDER_DISPLAY_NAMES[provider] ?? provider; - const usageMessage = usage?.detail ?? usage?.summary ?? null; const usageWindows = usage?.windows ?? []; const { sessionWindow, weeklyWindow } = selectPrimaryUsageWindows({ windows: usageWindows, @@ -105,50 +66,41 @@ export const ProviderUsageNotice = memo(function ProviderUsageNotice({ ]; return ( -
-
-
- {providerLabel} usage - {usage ? ( - - {statusLabel(usage.status)} - - ) : null} - {usage?.stale ? ( - Stale - ) : null} - {usage?.identity.planName ? Plan: {usage.identity.planName} : null} - {onDismiss ? ( - - ) : null} -
- {usageMessage ? ( -

- {usageMessage} -

- ) : !usage ? ( -

+

+
+ {onDismiss ? ( + + ) : null} + {!usage ? ( +

No usage snapshot available yet for this provider.

) : null} {usage ? ( -
+
{rows.map((row) => { const percentUsed = clampUsagePercentUsed(row.window?.percentUsed ?? null); const percentRemaining = toRemainingUsagePercent(percentUsed); return (
{row.label} @@ -173,30 +125,6 @@ export const ProviderUsageNotice = memo(function ProviderUsageNotice({ })}
) : null} - {metadata.usageUrl || metadata.dashboardUrl ? ( -
- {metadata.usageUrl ? ( - - Usage - - ) : null} - {metadata.dashboardUrl ? ( - - {metadata.dashboardLabel ?? "Dashboard"} - - ) : null} -
- ) : null}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea8af13d..eb679f43 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -4,6 +4,7 @@ export { SettingsModelsPanel } from "./SettingsModelsPanel"; export { SettingsGitPanel } from "./SettingsGitPanel"; export { SettingsEnhancePanel } from "./SettingsEnhancePanel"; export { SettingsProvidersPanel } from "./SettingsProvidersPanel"; +export { SettingsUsagePanel } from "./SettingsUsagePanel"; export { SettingsAdvancedPanel } from "./SettingsAdvancedPanel"; export { SettingsAboutPanel } from "./SettingsAboutPanel"; export { ArchivedThreadsPanel } from "./SettingsArchivedPanel"; diff --git a/apps/web/src/components/settings/SettingsProviderUsageSection.tsx b/apps/web/src/components/settings/SettingsProviderUsageSection.tsx new file mode 100644 index 00000000..76da04b1 --- /dev/null +++ b/apps/web/src/components/settings/SettingsProviderUsageSection.tsx @@ -0,0 +1,289 @@ +import type { ServerProviderUsage, ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { ProviderUsageMetadata } from "@t3tools/shared/provider-usage"; + +import { cn } from "../../lib/utils"; +import { + clampUsagePercentUsed, + deriveSessionResetFromWeeklyReset, + selectPrimaryUsageWindows, + toRemainingUsagePercent, +} from "../../providerUsageDisplay"; + +function getUsageStatusLabel(status: "ready" | "limited" | "exhausted" | "unknown" | "error") { + switch (status) { + case "ready": + return "Usage healthy"; + case "limited": + return "Usage limited"; + case "exhausted": + return "Usage exhausted"; + case "error": + return "Usage check failed"; + case "unknown": + default: + return "Usage unknown"; + } +} + +function normalizeUsageText(value: string): string { + return value.trim().replaceAll(/\s+/g, " ").toLowerCase(); +} + +function shouldHideUsageSummary(input: { + readonly summary: string | null; + readonly planName: string | null; + readonly loginMethod: string | null; +}): boolean { + if (!input.summary) { + return false; + } + + const normalizedSummary = normalizeUsageText(input.summary); + if (input.planName) { + const normalizedPlan = normalizeUsageText(input.planName); + if ( + normalizedSummary === `plan: ${normalizedPlan}` || + normalizedSummary === `plan ${normalizedPlan}` + ) { + return true; + } + } + + if (input.loginMethod) { + const normalizedLoginMethod = normalizeUsageText(input.loginMethod); + if ( + normalizedSummary === `login: ${normalizedLoginMethod}` || + normalizedSummary === `login ${normalizedLoginMethod}` + ) { + return true; + } + } + + return false; +} + +function usageBarToneClass(percentRemaining: number | null): string { + if (percentRemaining === null) { + return "bg-muted-foreground/30"; + } + if (percentRemaining <= 5) { + return "bg-destructive"; + } + if (percentRemaining <= 20) { + return "bg-warning"; + } + return "bg-success"; +} + +function isSameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function formatUsageResetLabel(resetAt: string): string { + const resetDate = new Date(resetAt); + if (Number.isNaN(resetDate.getTime())) { + return `Resets ${resetAt}`; + } + + const now = new Date(); + const formatted = isSameLocalDay(resetDate, now) + ? new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(resetDate) + : new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }) + .format(resetDate) + .replace(", ", " "); + + return `Resets ${formatted}`; +} + +function getUsageLimitTitle(input: { key: "session" | "weekly"; label: string }): string { + if (input.key === "session") { + return "5 hour usage limit"; + } + return `${input.label} usage limit`; +} + +function getUsageWindowUnavailableLabel(window: { percentUsed: number | null } | null): string { + if (window) { + return "Usage data unavailable"; + } + return "Usage source unavailable"; +} + +function hasRenderableUsagePercent(window: { percentUsed: number | null } | null): boolean { + return clampUsagePercentUsed(window?.percentUsed ?? null) !== null; +} + +interface UsageBarRow { + readonly key: "session" | "weekly"; + readonly label: string; + readonly window: ServerProviderUsageWindow | null; +} + +function buildUsageBarRows(input: { + readonly usage: ServerProviderUsage; + readonly metadata: ProviderUsageMetadata; +}): ReadonlyArray { + const usageWindows = input.usage.windows; + const { sessionWindow, weeklyWindow } = selectPrimaryUsageWindows({ + windows: usageWindows, + sessionLabel: input.metadata.sessionLabel, + weeklyLabel: input.metadata.weeklyLabel, + }); + const sessionResetAt = + sessionWindow?.resetAt ?? + deriveSessionResetFromWeeklyReset({ + weeklyResetAt: weeklyWindow?.resetAt ?? null, + }); + + return [ + { + key: "session", + label: input.metadata.sessionLabel, + window: sessionWindow + ? { + ...sessionWindow, + resetAt: sessionResetAt, + } + : sessionResetAt + ? { + key: "derived-session-reset", + label: input.metadata.sessionLabel, + percentUsed: null, + resetAt: sessionResetAt, + } + : null, + }, + { + key: "weekly", + label: input.metadata.weeklyLabel, + window: weeklyWindow, + }, + ]; +} + +export function SettingsProviderUsageSection({ + usage, + metadata, +}: { + usage: ServerProviderUsage; + metadata: ProviderUsageMetadata; +}) { + const usageSummary = !shouldHideUsageSummary({ + summary: usage.summary, + planName: usage.identity.planName, + loginMethod: usage.identity.loginMethod, + }) + ? usage.summary + : null; + const usageDetail = usage.detail ?? null; + const usageBarRows = buildUsageBarRows({ usage, metadata }); + + return ( +
+
+ {getUsageStatusLabel(usage.status)} + {usage.stale ? ( + Stale + ) : null} + {usage.identity.planName ? Plan: {usage.identity.planName} : null} + {usage.identity.loginMethod ? Login: {usage.identity.loginMethod} : null} +
+ {usageSummary || usageDetail ? ( +

+ {usageSummary ?? usageDetail} + {usageSummary && usageDetail ? ` - ${usageDetail}` : null} +

+ ) : null} +
+ {usageBarRows.map((row) => { + const percentUsed = clampUsagePercentUsed(row.window?.percentUsed ?? null); + const percentRemaining = toRemainingUsagePercent(percentUsed); + const hasRemaining = percentRemaining !== null && hasRenderableUsagePercent(row.window); + const unavailableLabel = getUsageWindowUnavailableLabel(row.window); + const primaryStatusLabel = row.window?.resetAt + ? formatUsageResetLabel(row.window.resetAt) + : unavailableLabel; + return ( +
+

+ {getUsageLimitTitle({ key: row.key, label: row.label })} +

+
+ {hasRemaining ? ( + <> + + {`${Math.round(percentRemaining)}%`} + + remaining + + ) : ( + + {primaryStatusLabel} + + )} +
+
+
+
+

{primaryStatusLabel}

+
+ ); + })} +
+
+ {metadata.usageUrl ? ( + + Usage + + ) : null} + {metadata.dashboardUrl ? ( + + {metadata.dashboardLabel ?? "Dashboard"} + + ) : null} + {metadata.statusPageUrl ? ( + + Status + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsProvidersSection.tsx b/apps/web/src/components/settings/SettingsProvidersSection.tsx index 69cab1ee..236ec716 100644 --- a/apps/web/src/components/settings/SettingsProvidersSection.tsx +++ b/apps/web/src/components/settings/SettingsProvidersSection.tsx @@ -1,11 +1,4 @@ -import { - ChevronDownIcon, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - XIcon, -} from "lucide-react"; +import { ChevronDownIcon, InfoIcon, PlusIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, @@ -15,19 +8,13 @@ import { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { normalizeModelSlug, resolveUtilityModelSelectionDefault } from "@t3tools/shared/model"; -import { PROVIDER_USAGE_METADATA } from "@t3tools/shared/provider-usage"; import { Equal } from "effect"; import { cn } from "../../lib/utils"; import { MAX_CUSTOM_MODEL_LENGTH, resolveAppModelSelectionState } from "../../modelSelection"; import { ensureNativeApi } from "../../nativeApi"; -import { - clampUsagePercentUsed, - selectPrimaryUsageWindows, - toRemainingUsagePercent, -} from "../../providerUsageDisplay"; -import { useProviderUsages } from "../../rpc/providerUsageState"; import { useServerProviders } from "../../rpc/serverState"; +import { formatRelativeTime } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Input } from "../ui/input"; @@ -35,7 +22,6 @@ import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingResetButton, SettingsSection } from "./SettingsPanelPrimitives"; import { PROVIDER_SETTINGS } from "./settingsProviderConfig"; -import { formatRelativeTime } from "../../timestampFormat"; type SettingsUpdater = (patch: Partial) => void; @@ -112,112 +98,6 @@ function getProviderVersionLabel(version: string | null | undefined) { return version.startsWith("v") ? version : `v${version}`; } -function getUsageStatusLabel(status: "ready" | "limited" | "exhausted" | "unknown" | "error") { - switch (status) { - case "ready": - return "Usage healthy"; - case "limited": - return "Usage limited"; - case "exhausted": - return "Usage exhausted"; - case "error": - return "Usage check failed"; - case "unknown": - default: - return "Usage unknown"; - } -} - -function normalizeUsageText(value: string): string { - return value.trim().replaceAll(/\s+/g, " ").toLowerCase(); -} - -function shouldHideUsageSummary(input: { - readonly summary: string | null; - readonly planName: string | null; - readonly loginMethod: string | null; -}): boolean { - if (!input.summary) { - return false; - } - - const normalizedSummary = normalizeUsageText(input.summary); - if (input.planName) { - const normalizedPlan = normalizeUsageText(input.planName); - if ( - normalizedSummary === `plan: ${normalizedPlan}` || - normalizedSummary === `plan ${normalizedPlan}` - ) { - return true; - } - } - - if (input.loginMethod) { - const normalizedLoginMethod = normalizeUsageText(input.loginMethod); - if ( - normalizedSummary === `login: ${normalizedLoginMethod}` || - normalizedSummary === `login ${normalizedLoginMethod}` - ) { - return true; - } - } - - return false; -} - -function usageBarToneClass(percentRemaining: number | null): string { - if (percentRemaining === null) { - return "bg-muted-foreground/30"; - } - if (percentRemaining <= 5) { - return "bg-destructive"; - } - if (percentRemaining <= 20) { - return "bg-warning"; - } - return "bg-success"; -} - -function isSameLocalDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -function formatUsageResetLabel(resetAt: string): string { - const resetDate = new Date(resetAt); - if (Number.isNaN(resetDate.getTime())) { - return `Resets ${resetAt}`; - } - - const now = new Date(); - const formatted = isSameLocalDay(resetDate, now) - ? new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - }).format(resetDate) - : new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }) - .format(resetDate) - .replace(", ", " "); - - return `Resets ${formatted}`; -} - -function getUsageLimitTitle(input: { key: "session" | "weekly"; label: string }): string { - if (input.key === "session") { - return "5 hour usage limit"; - } - return `${input.label} usage limit`; -} - function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; @@ -267,11 +147,8 @@ export function SettingsProvidersSection({ const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const refreshingRef = useRef(false); const modelListRefs = useRef>>({}); const serverProviders = useServerProviders(); - const providerUsages = useProviderUsages(); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; @@ -290,19 +167,10 @@ export function SettingsProvidersSection({ ).provider; const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - void Promise.all([ - ensureNativeApi().server.refreshProviders(), - ensureNativeApi().server.refreshUsageStatus(), - ]) + void ensureNativeApi() + .server.refreshProviders() .catch((error: unknown) => { console.warn("Failed to refresh providers", error); - }) - .finally(() => { - refreshingRef.current = false; - setIsRefreshingProviders(false); }); }, []); @@ -402,8 +270,6 @@ export function SettingsProvidersSection({ (candidate) => candidate.provider === providerSettings.provider, ); const providerConfig = settings.providers[providerSettings.provider]; - const providerUsage = - providerUsages.find((usage) => usage.provider === providerSettings.provider) ?? null; const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); const summary = getProviderSummary(liveProvider); @@ -431,8 +297,6 @@ export function SettingsProvidersSection({ statusStyle: PROVIDER_STATUS_STYLES[statusKey], summary, versionLabel: getProviderVersionLabel(liveProvider?.version), - providerUsage, - providerUsageMetadata: PROVIDER_USAGE_METADATA[providerSettings.provider], }; }); @@ -457,19 +321,14 @@ export function SettingsProvidersSection({ size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - disabled={isRefreshingProviders} onClick={() => void refreshProviders()} - aria-label="Refresh provider status and usage" + aria-label="Refresh provider status" > - {isRefreshingProviders ? ( - - ) : ( - - )} + } /> - Refresh provider status and usage + Refresh provider status
} @@ -479,34 +338,6 @@ export function SettingsProvidersSection({ const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; const providerDisplayName = PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; - const usageSummary = - providerCard.providerUsage && - !shouldHideUsageSummary({ - summary: providerCard.providerUsage.summary, - planName: providerCard.providerUsage.identity.planName, - loginMethod: providerCard.providerUsage.identity.loginMethod, - }) - ? providerCard.providerUsage.summary - : null; - const usageDetail = providerCard.providerUsage?.detail ?? null; - const usageWindows = providerCard.providerUsage?.windows ?? []; - const { sessionWindow, weeklyWindow } = selectPrimaryUsageWindows({ - windows: usageWindows, - sessionLabel: providerCard.providerUsageMetadata.sessionLabel, - weeklyLabel: providerCard.providerUsageMetadata.weeklyLabel, - }); - const usageBarRows = [ - { - key: "session" as const, - label: providerCard.providerUsageMetadata.sessionLabel, - window: sessionWindow, - }, - { - key: "weekly" as const, - label: providerCard.providerUsageMetadata.weeklyLabel, - window: weeklyWindow, - }, - ]; return (
@@ -548,105 +379,6 @@ export function SettingsProvidersSection({ {providerCard.summary.headline} {providerCard.summary.detail ? ` - ${providerCard.summary.detail}` : null}

- {providerCard.providerUsage ? ( -
-
- - {getUsageStatusLabel(providerCard.providerUsage.status)} - - {providerCard.providerUsage.stale ? ( - - Stale - - ) : null} - {providerCard.providerUsage.identity.planName ? ( - Plan: {providerCard.providerUsage.identity.planName} - ) : null} - {providerCard.providerUsage.identity.loginMethod ? ( - Login: {providerCard.providerUsage.identity.loginMethod} - ) : null} -
- {usageSummary || usageDetail ? ( -

- {usageSummary ?? usageDetail} - {usageSummary && usageDetail ? ` - ${usageDetail}` : null} -

- ) : null} -
- {usageBarRows.map((row) => { - const percentUsed = clampUsagePercentUsed( - row.window?.percentUsed ?? null, - ); - const percentRemaining = toRemainingUsagePercent(percentUsed); - const hasRemaining = percentRemaining !== null; - return ( -
-

- {getUsageLimitTitle({ key: row.key, label: row.label })} -

-
- - {hasRemaining ? `${Math.round(percentRemaining)}%` : "--"} - - - remaining - -
-
-
-
-

- {row.window?.resetAt - ? formatUsageResetLabel(row.window.resetAt) - : "Resets unavailable"} -

-
- ); - })} -
-
- {providerCard.providerUsageMetadata.usageUrl ? ( - - Usage - - ) : null} - {providerCard.providerUsageMetadata.dashboardUrl ? ( - - {providerCard.providerUsageMetadata.dashboardLabel ?? "Dashboard"} - - ) : null} - {providerCard.providerUsageMetadata.statusPageUrl ? ( - - Status - - ) : null} -
-
- ) : null}
+ } + /> + Refresh usage + +
+
+ {providerUsages.map((usage) => { + const metadata = PROVIDER_USAGE_METADATA[usage.provider]; + const providerDisplayName = + PROVIDER_DISPLAY_NAMES[usage.provider] ?? metadata.displayName; + + return ( +
+
+

{providerDisplayName}

+
+ +
+ ); + })} +
+ + + ); +} diff --git a/apps/web/src/providerUsageDisplay.ts b/apps/web/src/providerUsageDisplay.ts index c895401d..01d080e2 100644 --- a/apps/web/src/providerUsageDisplay.ts +++ b/apps/web/src/providerUsageDisplay.ts @@ -14,6 +14,8 @@ export function toRemainingUsagePercent(percentUsed: number | null): number | nu return Math.max(0, Math.min(100, 100 - percentUsed)); } +const FIVE_HOUR_WINDOW_MS = 5 * 60 * 60 * 1000; + function normalizeUsageWindowToken(value: string): string { return value.trim().replaceAll(/\s+/g, " ").toLowerCase(); } @@ -100,3 +102,29 @@ export function selectPrimaryUsageWindows(input: { return { sessionWindow, weeklyWindow }; } + +export function deriveSessionResetFromWeeklyReset(input: { + readonly weeklyResetAt: string | null; + readonly now?: Date; +}): string | null { + if (!input.weeklyResetAt) { + return null; + } + + const weeklyResetMs = Date.parse(input.weeklyResetAt); + if (Number.isNaN(weeklyResetMs)) { + return null; + } + + const nowMs = (input.now ?? new Date()).getTime(); + if (nowMs >= weeklyResetMs) { + return new Date(weeklyResetMs).toISOString(); + } + + const remainingMs = weeklyResetMs - nowMs; + const nextSessionResetOffset = remainingMs % FIVE_HOUR_WINDOW_MS; + const derivedResetMs = + nextSessionResetOffset === 0 ? weeklyResetMs : nowMs + nextSessionResetOffset; + + return new Date(derivedResetMs).toISOString(); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3b5710aa..45ac1bfe 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsUsageRouteImport } from './routes/settings.usage' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsModelsRouteImport } from './routes/settings.models' import { Route as SettingsGitRouteImport } from './routes/settings.git' @@ -37,6 +38,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsUsageRoute = SettingsUsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => SettingsRoute, +} as any) const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ id: '/providers', path: '/providers', @@ -101,6 +107,7 @@ export interface FileRoutesByFullPath { '/settings/git': typeof SettingsGitRoute '/settings/models': typeof SettingsModelsRoute '/settings/providers': typeof SettingsProvidersRoute + '/settings/usage': typeof SettingsUsageRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren @@ -114,6 +121,7 @@ export interface FileRoutesByTo { '/settings/git': typeof SettingsGitRoute '/settings/models': typeof SettingsModelsRoute '/settings/providers': typeof SettingsProvidersRoute + '/settings/usage': typeof SettingsUsageRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { @@ -130,6 +138,7 @@ export interface FileRoutesById { '/settings/git': typeof SettingsGitRoute '/settings/models': typeof SettingsModelsRoute '/settings/providers': typeof SettingsProvidersRoute + '/settings/usage': typeof SettingsUsageRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { @@ -147,6 +156,7 @@ export interface FileRouteTypes { | '/settings/git' | '/settings/models' | '/settings/providers' + | '/settings/usage' fileRoutesByTo: FileRoutesByTo to: | '/settings' @@ -160,6 +170,7 @@ export interface FileRouteTypes { | '/settings/git' | '/settings/models' | '/settings/providers' + | '/settings/usage' | '/' id: | '__root__' @@ -175,6 +186,7 @@ export interface FileRouteTypes { | '/settings/git' | '/settings/models' | '/settings/providers' + | '/settings/usage' | '/_chat/' fileRoutesById: FileRoutesById } @@ -206,6 +218,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/usage': { + id: '/settings/usage' + path: '/usage' + fullPath: '/settings/usage' + preLoaderRoute: typeof SettingsUsageRouteImport + parentRoute: typeof SettingsRoute + } '/settings/providers': { id: '/settings/providers' path: '/providers' @@ -301,6 +320,7 @@ interface SettingsRouteChildren { SettingsGitRoute: typeof SettingsGitRoute SettingsModelsRoute: typeof SettingsModelsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute + SettingsUsageRoute: typeof SettingsUsageRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -313,6 +333,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGitRoute: SettingsGitRoute, SettingsModelsRoute: SettingsModelsRoute, SettingsProvidersRoute: SettingsProvidersRoute, + SettingsUsageRoute: SettingsUsageRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.usage.tsx b/apps/web/src/routes/settings.usage.tsx new file mode 100644 index 00000000..7f7da771 --- /dev/null +++ b/apps/web/src/routes/settings.usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SettingsUsagePanel } from "../components/settings/SettingsPanels"; + +export const Route = createFileRoute("/settings/usage")({ + component: SettingsUsagePanel, +});