From 211250e89e2be6967f51f78497cfcfdf36564179 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 25 May 2026 11:52:43 +0100 Subject: [PATCH] feat(billing): always-on Free sidebar bar with reset time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free users now see the usage bar with a reset-time line even while usage is still loading — the previous silent `return null` on `!usage` is what hid the bar from Free users in session replays. `useFreeUsage` exposes an `isLoading` flag so `SidebarUsageBar` can render a skeleton instead of disappearing. Reset time is rendered via a shared `formatResetTime` helper that prefers the new gateway `reset_at` ISO timestamp and falls back to `resets_in_seconds`, producing "Resets in 4h" / "Resets in 4h 30m" / "Resets May 30 at 12:00am PT" depending on the horizon. The duplicated formatter in `PlanUsageSettings` is replaced with the shared helper. Pro users still see nothing here — their notifications come via toasts in B2. Adds a `log.warn` on `fetchUsage` failures so we can see when the bar is silently hidden by a network/auth error. Generated-By: PostHog Code Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af Generated-By: PostHog Code Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af --- .../src/main/services/llm-gateway/schemas.ts | 4 ++ .../src/main/services/llm-gateway/service.ts | 11 ++++- .../billing/components/SidebarUsageBar.tsx | 44 +++++++++++++------ .../features/billing/hooks/useFreeUsage.ts | 18 +++++--- .../billing/hooks/useUsageLimitDetection.ts | 2 +- .../renderer/features/billing/utils.test.ts | 35 ++++++++++++++- .../src/renderer/features/billing/utils.ts | 35 +++++++++++++++ .../components/sections/PlanUsageSettings.tsx | 14 +----- 8 files changed, 129 insertions(+), 34 deletions(-) diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 8268e9067..06a735ca5 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -60,6 +60,9 @@ export interface AnthropicErrorResponse { export const usageBucketSchema = z.object({ used_percent: z.number(), resets_in_seconds: z.number(), + // Absolute UTC reset timestamp from gateway A1; preferred over the + // rolling resets_in_seconds, which drifts between polls. + reset_at: z.string().datetime().optional(), exceeded: z.boolean(), }); @@ -69,6 +72,7 @@ export const usageOutput = z.object({ sustained: usageBucketSchema, burst: usageBucketSchema, is_rate_limited: z.boolean(), + billing_period_end: z.string().datetime().nullable().optional(), }); export type UsageBucket = z.infer; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 6f2ac87e9..2a6ac5a26 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -175,9 +175,18 @@ export class LlmGatewayService { log.debug("Fetching usage from gateway", { url: usageUrl }); - const response = await this.authService.authenticatedFetch(fetch, usageUrl); + let response: Response; + try { + response = await this.authService.authenticatedFetch(fetch, usageUrl); + } catch (err) { + log.warn("Usage fetch network error", { + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } if (!response.ok) { + log.warn("Usage fetch failed", { status: response.status }); throw new LlmGatewayError( `Failed to fetch usage: HTTP ${response.status}`, "usage_error", diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx index 6211b5dcf..c18b21d12 100644 --- a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx +++ b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx @@ -1,5 +1,5 @@ import { useFreeUsage } from "@features/billing/hooks/useFreeUsage"; -import { isUsageExceeded } from "@features/billing/utils"; +import { formatResetTime, isUsageExceeded } from "@features/billing/utils"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Circle } from "@phosphor-icons/react"; @@ -7,20 +7,37 @@ import { BILLING_FLAG } from "@shared/constants"; export function SidebarUsageBar() { const billingEnabled = useFeatureFlag(BILLING_FLAG); - const usage = useFreeUsage(billingEnabled); + const { usage, isLoading } = useFreeUsage(billingEnabled); - if (!usage) return null; - - const usagePercent = Math.max( - usage.sustained.used_percent, - usage.burst.used_percent, - ); - const exceeded = isUsageExceeded(usage); + if (!billingEnabled) return null; const handleUpgrade = () => { useSettingsDialogStore.getState().open("plan-usage"); }; + if (!usage) { + if (!isLoading) return null; + return ( +
+
+ Free plan +
+
+
+ ); + } + + const exceeded = isUsageExceeded(usage); + const dominant = + usage.sustained.used_percent >= usage.burst.used_percent + ? usage.sustained + : usage.burst; + const usagePercent = Math.min(Math.round(dominant.used_percent), 100); + const resetLabel = formatResetTime( + dominant.reset_at, + dominant.resets_in_seconds, + ); + return (
@@ -32,9 +49,7 @@ export function SidebarUsageBar() { className="mx-1.5 inline text-gray-8" /> - {exceeded - ? "Limit reached" - : `${Math.min(Math.round(usagePercent), 100)}% used`} + {exceeded ? "Limit reached" : `${usagePercent}% used`}
); } diff --git a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts b/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts index fb20fa6f1..bfcf56a80 100644 --- a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts +++ b/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts @@ -2,13 +2,19 @@ import { useSeat } from "@hooks/useSeat"; import type { UsageOutput } from "@main/services/llm-gateway/schemas"; import { useUsage } from "./useUsage"; -export function useFreeUsage(billingEnabled: boolean): UsageOutput | null { +export interface FreeUsageResult { + usage: UsageOutput | null; + // True when the user is eligible to see the Free sidebar bar but data + // hasn't arrived yet. Distinguishes "show skeleton" from "render nothing". + isLoading: boolean; +} + +export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { const { seat, isPro } = useSeat(); const seatLoaded = seat !== null; - const { usage } = useUsage({ - enabled: billingEnabled && seatLoaded && !isPro, - }); + const eligible = billingEnabled && seatLoaded && !isPro; + const { usage, isLoading } = useUsage({ enabled: eligible }); - if (!billingEnabled || !seatLoaded || isPro || !usage) return null; - return usage; + if (!eligible) return { usage: null, isLoading: false }; + return { usage: usage ?? null, isLoading }; } diff --git a/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts b/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts index ac08dae2d..8f7f685c8 100644 --- a/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts +++ b/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts @@ -5,7 +5,7 @@ import { useEffect, useRef } from "react"; import { useFreeUsage } from "./useFreeUsage"; export function useUsageLimitDetection(billingEnabled: boolean) { - const usage = useFreeUsage(billingEnabled); + const { usage } = useFreeUsage(billingEnabled); const hasAlertedRef = useRef(false); useEffect(() => { diff --git a/apps/code/src/renderer/features/billing/utils.test.ts b/apps/code/src/renderer/features/billing/utils.test.ts index 8e8db3c3f..3a6f6891d 100644 --- a/apps/code/src/renderer/features/billing/utils.test.ts +++ b/apps/code/src/renderer/features/billing/utils.test.ts @@ -1,6 +1,6 @@ import type { UsageOutput } from "@main/services/llm-gateway/schemas"; import { describe, expect, it } from "vitest"; -import { isUsageExceeded } from "./utils"; +import { formatResetTime, isUsageExceeded } from "./utils"; function makeUsage( overrides: Partial<{ @@ -51,3 +51,36 @@ describe("isUsageExceeded", () => { ).toBe(true); }); }); + +describe("formatResetTime", () => { + const NOW = Date.parse("2026-05-01T12:00:00.000Z"); + + it("returns minutes-only under 1h", () => { + expect(formatResetTime(undefined, 30 * 60, NOW)).toBe("Resets in 30m"); + }); + + it("returns hours + minutes under 24h", () => { + expect(formatResetTime(undefined, 4 * 3600 + 30 * 60, NOW)).toBe( + "Resets in 4h 30m", + ); + }); + + it("returns hours only when minutes round to 0", () => { + expect(formatResetTime(undefined, 4 * 3600, NOW)).toBe("Resets in 4h"); + }); + + it("returns localized date when over 24h away", () => { + const result = formatResetTime(undefined, 30 * 86400, NOW); + expect(result).toMatch(/^Resets [A-Za-z]+ \d+ at /); + }); + + it("prefers reset_at over the fallback seconds", () => { + const iso = new Date(NOW + 4 * 3600 * 1000).toISOString(); + expect(formatResetTime(iso, 99999, NOW)).toBe("Resets in 4h"); + }); + + it("treats an already-past reset_at as shortly", () => { + const iso = new Date(NOW - 60_000).toISOString(); + expect(formatResetTime(iso, 0, NOW)).toBe("Resets shortly"); + }); +}); diff --git a/apps/code/src/renderer/features/billing/utils.ts b/apps/code/src/renderer/features/billing/utils.ts index f0ad86830..10117b10d 100644 --- a/apps/code/src/renderer/features/billing/utils.ts +++ b/apps/code/src/renderer/features/billing/utils.ts @@ -5,3 +5,38 @@ export function isUsageExceeded(usage: UsageOutput): boolean { usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded ); } + +export function formatResetTime( + resetAtIso: string | undefined, + fallbackSeconds: number, + now: number = Date.now(), +): string { + const ms = resetAtIso + ? Math.max(0, Date.parse(resetAtIso) - now) + : Math.max(0, fallbackSeconds * 1000); + + const totalMinutes = Math.ceil(ms / 60_000); + if (totalMinutes <= 0) return "Resets shortly"; + if (totalMinutes < 60) return `Resets in ${totalMinutes}m`; + + const totalHours = ms / 3_600_000; + if (totalHours < 24) { + const hours = Math.floor(totalHours); + const minutes = Math.round((totalHours - hours) * 60); + return minutes === 0 + ? `Resets in ${hours}h` + : `Resets in ${hours}h ${minutes}m`; + } + + const target = new Date(now + ms); + const date = target.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + const time = target.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); + return `Resets ${date} at ${time}`; +} diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index ea5b9c7bd..62cdc3dcc 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -3,6 +3,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { TokenSpendAnalysisBanner } from "@features/billing/components/TokenSpendAnalysisBanner"; import { useUsage } from "@features/billing/hooks/useUsage"; import { useSeatStore } from "@features/billing/stores/seatStore"; +import { formatResetTime } from "@features/billing/utils"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useSeat } from "@hooks/useSeat"; import type { UsageBucket } from "@main/services/llm-gateway/schemas"; @@ -47,17 +48,6 @@ async function openBillingPage(orgId: string | null): Promise { if (url) window.open(url, "_blank"); } -function formatResetTime(seconds: number): string { - if (seconds < 3600) return "less than 1 hour"; - if (seconds < 86400) { - const hours = Math.ceil(seconds / 3600); - return hours === 1 ? "1 hour" : `${hours} hours`; - } - const days = Math.ceil(seconds / 86400); - if (days === 1) return "1 day"; - return `${days} days`; -} - export function PlanUsageSettings() { const { seat, @@ -452,7 +442,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) { {bucket.exceeded ? "Limit exceeded" - : `Resets in ${formatResetTime(bucket.resets_in_seconds)}`} + : formatResetTime(bucket.reset_at, bucket.resets_in_seconds)} );