From 9d9d5daea062d88dddb985eb7ccd2da8d28bbe93 Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Mon, 24 Aug 2026 16:59:13 -0500 Subject: [PATCH 1/3] feat(web): show provider plan usage in sidebar - collect Codex and Claude subscription limit windows\n- add an opt-in compact footer indicator with threshold colors\n- document and test provider quota normalization and settings --- .../settings/DesktopClientSettings.test.ts | 1 + .../Layers/ClaudeCapabilitiesProbe.test.ts | 34 +++- .../src/provider/Layers/ClaudeProvider.ts | 11 +- .../src/provider/Layers/CodexProvider.ts | 9 +- .../src/provider/providerPlanUsage.test.ts | 106 +++++++++++ apps/server/src/provider/providerPlanUsage.ts | 173 ++++++++++++++++++ apps/server/src/provider/providerSnapshot.ts | 3 + .../components/settings/SettingsPanels.tsx | 31 ++++ .../src/components/settings/settingsSearch.ts | 5 + .../src/components/sidebar/SidebarChrome.tsx | 98 +++++++++- .../sidebar/SidebarPlanUsage.logic.test.ts | 73 ++++++++ .../sidebar/SidebarPlanUsage.logic.ts | 53 ++++++ docs/user/usage.md | 7 + packages/contracts/src/server.test.ts | 29 +++ packages/contracts/src/server.ts | 18 ++ packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 2 + 17 files changed, 650 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/provider/providerPlanUsage.test.ts create mode 100644 apps/server/src/provider/providerPlanUsage.ts create mode 100644 apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts create mode 100644 apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..5394bc25f88f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, planModeEnabled: false, + showPlanUsageInSidebar: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..dbe1feef9c4d 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -89,7 +89,35 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " session: {", + " total_cost_usd: 0,", + " total_api_duration_ms: 0,", + " total_duration_ms: 0,", + " total_lines_added: 0,", + " total_lines_removed: 0,", + " model_usage: {},", + " },", + ' subscription_type: "pro",', + " rate_limits_available: true,", + " rate_limits: {", + ' seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" },', + ' seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" },', + " },", + " behaviors: null,", + " },", + " },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -127,6 +155,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { subscriptionType: "pro", tokenSource: "oauth", apiProvider: undefined, + rateLimits: { + seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" }, + seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" }, + }, slashCommands: [ { name: "review", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..849494e2adaf 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -42,6 +42,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { normalizeClaudePlanUsage } from "../providerPlanUsage.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -641,6 +642,7 @@ type ClaudeCapabilitiesProbe = { * the subscription/token fields are absent and auth is external AWS creds. */ readonly apiProvider: string | undefined; + readonly rateLimits?: unknown; readonly slashCommands: ReadonlyArray; }; @@ -724,7 +726,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * message is ever written to the subprocess stdin. This means the Claude * Code subprocess completes its local initialization IPC (returning * account info and slash commands) but never starts an API request to - * Anthropic. We read the init data and then abort the subprocess. + * Anthropic. We read the init data, request account usage over the SDK's + * control channel when available, and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -765,11 +768,15 @@ const probeClaudeCapabilities = ( readonly apiProvider?: string; } | undefined; + const usage = await q + .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() + .catch(() => null); return { email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, + rateLimits: usage?.rate_limits as unknown, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -953,6 +960,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + const planUsage = normalizeClaudePlanUsage(capabilities.rateLimits, checkedAt); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -960,6 +968,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models, slashCommands: dedupedSlashCommands, skills, + ...(planUsage ? { planUsage } : {}), probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..a32a9b103b0d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,6 +33,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { normalizeCodexPlanUsage } from "../providerPlanUsage.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse | undefined; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -395,24 +397,27 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, + rateLimits: undefined, version, models: appendCustomCodexModels([], input.customModels ?? []), skills: [], } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + client.request("account/rateLimits/read", undefined).pipe(Effect.option), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + rateLimits: Option.getOrUndefined(rateLimits), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -600,6 +605,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const planUsage = normalizeCodexPlanUsage(snapshot.rateLimits, checkedAt); return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -607,6 +613,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + ...(planUsage ? { planUsage } : {}), slashCommands: [ { name: "feedback", diff --git a/apps/server/src/provider/providerPlanUsage.test.ts b/apps/server/src/provider/providerPlanUsage.test.ts new file mode 100644 index 000000000000..91a497766705 --- /dev/null +++ b/apps/server/src/provider/providerPlanUsage.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeClaudePlanUsage, normalizeCodexPlanUsage } from "./providerPlanUsage.ts"; + +const checkedAt = "2026-08-24T18:00:00.000Z"; + +describe("provider plan usage", () => { + it("normalizes Codex primary and weekly windows", () => { + expect( + normalizeCodexPlanUsage( + { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 31, windowDurationMins: 300, resetsAt: 1_777_000_000 }, + secondary: { usedPercent: 72, windowDurationMins: 10_080, resetsAt: 1_777_500_000 }, + }, + }, + checkedAt, + ), + ).toEqual({ + checkedAt, + windows: [ + { + id: "codex:primary", + label: "5-hour", + usedPercent: 31, + resetsAt: "2026-04-24T03:06:40.000Z", + windowDurationMinutes: 300, + }, + { + id: "codex:secondary", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-04-29T22:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }); + }); + + it("keeps named Codex buckets distinct and clamps percentages", () => { + const usage = normalizeCodexPlanUsage( + { + rateLimitsByLimitId: { + codex: { limitName: "Codex", primary: { usedPercent: -1 } }, + codex_fast: { limitName: "Fast", primary: { usedPercent: 120 } }, + }, + }, + checkedAt, + ); + + expect(usage?.windows).toEqual([ + { + id: "codex:primary", + label: "Primary", + usedPercent: 0, + resetsAt: null, + }, + { + id: "codex_fast:primary", + label: "Fast · Primary", + usedPercent: 100, + resetsAt: null, + }, + ]); + }); + + it("normalizes Claude all-models and forward-compatible Fable windows", () => { + expect( + normalizeClaudePlanUsage( + { + seven_day: { utilization: 10, resets_at: "2026-08-29T01:00:00Z" }, + seven_day_fable: { utilization: 18, resets_at: "2026-08-29T01:00:00Z" }, + extra_usage: { utilization: 70 }, + }, + checkedAt, + ), + ).toEqual({ + checkedAt, + windows: [ + { + id: "seven_day", + label: "All models", + usedPercent: 10, + resetsAt: "2026-08-29T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + { + id: "seven_day_fable", + label: "Fable", + usedPercent: 18, + resetsAt: "2026-08-29T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }); + }); + + it("omits unavailable and malformed usage", () => { + expect(normalizeCodexPlanUsage({}, checkedAt)).toBeUndefined(); + expect(normalizeClaudePlanUsage(null, checkedAt)).toBeUndefined(); + expect( + normalizeClaudePlanUsage({ seven_day: { utilization: "10" } }, checkedAt), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerPlanUsage.ts b/apps/server/src/provider/providerPlanUsage.ts new file mode 100644 index 000000000000..45ed1b561479 --- /dev/null +++ b/apps/server/src/provider/providerPlanUsage.ts @@ -0,0 +1,173 @@ +import type { ProviderPlanUsage, ProviderPlanUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +type UnknownRecord = Readonly>; + +function asRecord(value: unknown): UnknownRecord | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UnknownRecord) + : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function normalizedPercent(value: unknown): number | null { + const percent = asFiniteNumber(value); + return percent === null ? null : Math.max(0, Math.min(100, percent)); +} + +function isoFromUnixSeconds(value: unknown): string | null { + const seconds = asFiniteNumber(value); + if (seconds === null) return null; + return Option.match(DateTime.make(seconds * 1_000), { + onNone: () => null, + onSome: DateTime.formatIso, + }); +} + +function isoFromString(value: unknown): string | null { + if (typeof value !== "string") return null; + return Option.match(DateTime.make(value), { + onNone: () => null, + onSome: DateTime.formatIso, + }); +} + +function codexDurationLabel(durationMinutes: number | null, role: "primary" | "secondary") { + if (durationMinutes === 5 * 60) return "5-hour"; + if (durationMinutes === 7 * 24 * 60) return "Weekly"; + if (durationMinutes !== null && durationMinutes > 0 && durationMinutes % 60 === 0) { + return `${durationMinutes / 60}-hour`; + } + return role === "primary" ? "Primary" : "Secondary"; +} + +function codexWindows( + bucket: UnknownRecord, + bucketId: string, + includeBucketLabel: boolean, +): ProviderPlanUsageWindow[] { + const bucketLabel = + typeof bucket.limitName === "string" && bucket.limitName.trim() + ? bucket.limitName.trim() + : bucketId; + const windows: ProviderPlanUsageWindow[] = []; + + for (const role of ["primary", "secondary"] as const) { + const window = asRecord(bucket[role]); + if (!window) continue; + const usedPercent = normalizedPercent(window.usedPercent); + if (usedPercent === null) continue; + const durationMinutes = asFiniteNumber(window.windowDurationMins); + const windowLabel = codexDurationLabel(durationMinutes, role); + windows.push({ + id: `${bucketId}:${role}`, + label: includeBucketLabel ? `${bucketLabel} · ${windowLabel}` : windowLabel, + usedPercent, + resetsAt: isoFromUnixSeconds(window.resetsAt), + ...(durationMinutes !== null && durationMinutes >= 0 + ? { windowDurationMinutes: Math.floor(durationMinutes) } + : {}), + }); + } + + return windows; +} + +/** Normalize Codex app-server's multi-bucket rate-limit response. */ +export function normalizeCodexPlanUsage( + value: unknown, + checkedAt: string, +): ProviderPlanUsage | undefined { + const response = asRecord(value); + if (!response) return undefined; + const byLimitId = asRecord(response.rateLimitsByLimitId); + const entries = byLimitId + ? Object.entries(byLimitId).flatMap(([id, bucket]) => { + const record = asRecord(bucket); + return record ? ([[id, record]] as const) : []; + }) + : []; + const fallback = asRecord(response.rateLimits); + const buckets = + entries.length > 0 + ? entries + : fallback + ? ([[typeof fallback.limitId === "string" ? fallback.limitId : "codex", fallback]] as const) + : []; + const windows = buckets.flatMap(([id, bucket]) => { + const limitName = typeof bucket.limitName === "string" ? bucket.limitName.trim() : ""; + const isDefaultBucket = id.toLowerCase() === "codex" || limitName.toLowerCase() === "codex"; + return codexWindows(bucket, id, buckets.length > 1 && !isDefaultBucket); + }); + return windows.length > 0 ? { checkedAt, windows } : undefined; +} + +const CLAUDE_WINDOW_LABELS: Readonly> = { + five_hour: "Current session", + seven_day: "All models", + seven_day_fable: "Fable", + seven_day_opus: "Opus", + seven_day_sonnet: "Sonnet", + seven_day_oauth_apps: "OAuth apps", +}; + +const CLAUDE_WINDOW_ORDER = [ + "five_hour", + "seven_day", + "seven_day_fable", + "seven_day_opus", + "seven_day_sonnet", + "seven_day_oauth_apps", +] as const; + +function claudeWindowLabel(id: string): string { + const known = CLAUDE_WINDOW_LABELS[id]; + if (known) return known; + return id + .replace(/^seven_day_/, "") + .split("_") + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +/** Normalize Claude Agent SDK's structured `/usage` rate-limit windows. */ +export function normalizeClaudePlanUsage( + value: unknown, + checkedAt: string, +): ProviderPlanUsage | undefined { + const rateLimits = asRecord(value); + if (!rateLimits) return undefined; + const orderedKeys = [ + ...CLAUDE_WINDOW_ORDER, + ...Object.keys(rateLimits) + .filter((key) => !CLAUDE_WINDOW_ORDER.includes(key as (typeof CLAUDE_WINDOW_ORDER)[number])) + .sort(), + ]; + const windows: ProviderPlanUsageWindow[] = []; + + for (const id of orderedKeys) { + if (id === "extra_usage") continue; + const window = asRecord(rateLimits[id]); + if (!window) continue; + const usedPercent = normalizedPercent(window.utilization); + if (usedPercent === null) continue; + windows.push({ + id, + label: claudeWindowLabel(id), + usedPercent, + resetsAt: isoFromString(window.resets_at), + ...(id === "five_hour" + ? { windowDurationMinutes: 5 * 60 } + : id.startsWith("seven_day") + ? { windowDurationMinutes: 7 * 24 * 60 } + : {}), + }); + } + + return windows.length > 0 ? { checkedAt, windows } : undefined; +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index adbe110d9408..629c328b3410 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ModelCapabilities, + ProviderPlanUsage, ServerProvider, ServerProviderAuth, ServerProviderSkill, @@ -221,6 +222,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + planUsage?: ProviderPlanUsage; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -249,6 +251,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.planUsage ? { planUsage: input.planUsage } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..b82870ac1f1c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -510,6 +510,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), + ...(settings.showPlanUsageInSidebar !== DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar + ? ["Plan usage indicator"] + : []), ...(settings.enableLegacyTokenStreaming !== DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming ? ["Stream token by token"] @@ -576,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, + settings.showPlanUsageInSidebar, settings.showSkillsInSlashMenu, settings.timestampFormat, settings.wordWrap, @@ -653,6 +657,7 @@ export function useSettingsRestore(onRestored?: () => void) { wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, + showPlanUsageInSidebar: DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, @@ -2107,6 +2112,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + showPlanUsageInSidebar: DEFAULT_UNIFIED_SETTINGS.showPlanUsageInSidebar, + }) + } + /> + ) : null + } + control={ + + updateSettings({ showPlanUsageInSidebar: Boolean(checked) }) + } + aria-label="Show plan usage in sidebar" + /> + } + /> + void }) { + const showPlanUsage = useClientSettings((settings) => settings.showPlanUsageInSidebar); + const { environments } = useEnvironments(); + const entries = useMemo(() => collectSidebarPlanUsage(environments), [environments]); + const highestPercent = showPlanUsage ? highestPlanUsagePercent(entries) : null; + const roundedPercent = highestPercent === null ? null : Math.round(highestPercent); + const tone = highestPercent === null ? "muted" : sidebarPlanUsageTone(highestPercent); + const showEnvironment = environments.length > 1; + const label = + roundedPercent === null ? "Usage" : `Usage, highest plan utilization ${roundedPercent}%`; + + return ( + + + svg]:text-current!", + tone === "warning" && "text-warning hover:text-warning", + tone === "danger" && "text-destructive hover:text-destructive", + )} + onClick={onClick} + size={roundedPercent === null ? "icon" : "default"} + > + + {roundedPercent === null ? null : ( + {roundedPercent}% + )} + + } + /> + + {showPlanUsage && entries.length > 0 ? ( +
+
Plan usage
+ {entries.map((entry) => { + const reset = formatPlanUsageReset(entry.resetsAt); + return ( +
+
+
+ {entry.providerLabel} · {entry.windowLabel} +
+ {showEnvironment || reset ? ( +
+ {[ + showEnvironment ? entry.environmentLabel : null, + reset ? `Resets ${reset}` : null, + ] + .filter(Boolean) + .join(" · ")} +
+ ) : null} +
+ + {Math.round(entry.usedPercent)}% + +
+ ); + })} +
+ ) : ( + "Usage" + )} +
+
+
+ ); +} + export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const canGoBack = useCanGoBack(); @@ -216,11 +304,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> + )} diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts new file mode 100644 index 000000000000..ea24c778cdbd --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts @@ -0,0 +1,73 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + collectSidebarPlanUsage, + highestPlanUsagePercent, + sidebarPlanUsageTone, +} from "./SidebarPlanUsage.logic.ts"; + +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude", + enabled: true, + installed: true, + version: "2.1.219", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-24T18:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +describe("sidebar plan usage", () => { + it("collects every provider window with environment context", () => { + const entries = collectSidebarPlanUsage([ + { + label: "Local", + serverConfig: { + providers: [ + provider({ + planUsage: { + checkedAt: "2026-08-24T18:01:00.000Z", + windows: [ + { id: "seven_day", label: "All models", usedPercent: 10, resetsAt: null }, + { id: "seven_day_fable", label: "Fable", usedPercent: 18, resetsAt: null }, + ], + }, + }), + ], + }, + }, + ]); + + expect( + entries.map(({ providerLabel, windowLabel, usedPercent }) => ({ + providerLabel, + windowLabel, + usedPercent, + })), + ).toEqual([ + { providerLabel: "Claude", windowLabel: "All models", usedPercent: 10 }, + { providerLabel: "Claude", windowLabel: "Fable", usedPercent: 18 }, + ]); + expect(highestPlanUsagePercent(entries)).toBe(18); + }); + + it("uses the requested warning thresholds", () => { + expect(sidebarPlanUsageTone(69.9)).toBe("muted"); + expect(sidebarPlanUsageTone(70)).toBe("warning"); + expect(sidebarPlanUsageTone(89.9)).toBe("warning"); + expect(sidebarPlanUsageTone(90)).toBe("danger"); + }); + + it("reports no percentage before a provider has returned plan usage", () => { + expect(collectSidebarPlanUsage([{ label: "Local", serverConfig: null }])).toEqual([]); + expect(highestPlanUsagePercent([])).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts new file mode 100644 index 000000000000..56f0091d6c3a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts @@ -0,0 +1,53 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +export interface SidebarPlanUsageEnvironment { + readonly label: string; + readonly serverConfig: { + readonly providers: ReadonlyArray; + } | null; +} + +export interface SidebarPlanUsageEntry { + readonly key: string; + readonly environmentLabel: string; + readonly providerLabel: string; + readonly windowLabel: string; + readonly usedPercent: number; + readonly resetsAt: string | null; + readonly checkedAt: string; +} + +export type SidebarPlanUsageTone = "muted" | "warning" | "danger"; + +export function collectSidebarPlanUsage( + environments: ReadonlyArray, +): SidebarPlanUsageEntry[] { + return environments.flatMap((environment) => + (environment.serverConfig?.providers ?? []).flatMap((provider) => { + const planUsage = provider.planUsage; + if (!planUsage) return []; + return planUsage.windows.map((window) => ({ + key: `${environment.label}:${provider.instanceId}:${window.id}`, + environmentLabel: environment.label, + providerLabel: provider.displayName ?? provider.driver, + windowLabel: window.label, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt, + checkedAt: planUsage.checkedAt, + })); + }), + ); +} + +export function highestPlanUsagePercent( + entries: ReadonlyArray, +): number | null { + if (entries.length === 0) return null; + return entries.reduce((highest, entry) => Math.max(highest, entry.usedPercent), 0); +} + +export function sidebarPlanUsageTone(usedPercent: number): SidebarPlanUsageTone { + if (usedPercent >= 90) return "danger"; + if (usedPercent >= 70) return "warning"; + return "muted"; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..52488349db3b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -5,6 +5,13 @@ the providers' local session history and shows API-equivalent token cost, proces savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token cost shown here. +On web and desktop, enable **Show plan usage in sidebar** under **Settings → General** to put the +highest provider-reported subscription utilization beside the Usage icon. The number is muted below +70%, amber from 70%, and red from 90%. Hover it to see every reported limit, including separate +weekly model limits such as Claude Fable when the provider supplies them. Plan limits refresh with +provider health checks and disappear when a provider or account cannot report subscription usage. +Codex and Claude currently report plan limits; other providers keep the normal icon-only link. + Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart, and refreshing rescans every connected environment. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..c432a8a400de 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -45,6 +45,35 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(parsed.planUsage).toBeUndefined(); + }); + + it("decodes provider plan usage windows", () => { + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + planUsage: { + checkedAt: "2026-08-24T18:00:00.000Z", + windows: [ + { + id: "weekly", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-08-28T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ], + }, + }); + + expect(parsed.planUsage?.windows).toEqual([ + { + id: "weekly", + label: "Weekly", + usedPercent: 72, + resetsAt: "2026-08-28T01:00:00.000Z", + windowDurationMinutes: 10_080, + }, + ]); }); it("defaults one-click update support when decoding older advisory snapshots", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..d053a1a73fa5 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -61,6 +61,23 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +/** One subscription-plan quota window reported by a provider. */ +export const ProviderPlanUsageWindow = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), + resetsAt: Schema.NullOr(IsoDateTime), + windowDurationMinutes: Schema.optional(NonNegativeInt), +}); +export type ProviderPlanUsageWindow = typeof ProviderPlanUsageWindow.Type; + +/** Provider-authored plan utilization, sampled during the provider health probe. */ +export const ProviderPlanUsage = Schema.Struct({ + checkedAt: IsoDateTime, + windows: Schema.Array(ProviderPlanUsageWindow), +}); +export type ProviderPlanUsage = typeof ProviderPlanUsage.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -192,6 +209,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + planUsage: Schema.optionalKey(ProviderPlanUsage), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..815a905f7469 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -110,6 +110,13 @@ describe("ClientSettings sidebar", () => { ); }); + it("keeps provider plan usage hidden until the user opts in", () => { + expect(decodeClientSettings({}).showPlanUsageInSidebar).toBe(false); + expect(decodeClientSettingsPatch({ showPlanUsageInSidebar: true }).showPlanUsageInSidebar).toBe( + true, + ); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..c15cafff8c2b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -226,6 +226,7 @@ export const ClientSettingsSchema = Schema.Struct({ // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + showPlanUsageInSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the // old keys, so everyone, including prior beta opt-outs, resets to the new @@ -915,6 +916,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), + showPlanUsageInSidebar: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), From 17be5aebfd6bba24320ddd9ba932a54877585e92 Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Mon, 24 Aug 2026 17:10:30 -0500 Subject: [PATCH 2/3] docs: use visible plan usage setting name --- docs/user/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 52488349db3b..5a440b301fe3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -5,7 +5,7 @@ the providers' local session history and shows API-equivalent token cost, proces savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token cost shown here. -On web and desktop, enable **Show plan usage in sidebar** under **Settings → General** to put the +On web and desktop, enable **Plan usage indicator** under **Settings → General** to put the highest provider-reported subscription utilization beside the Usage icon. The number is muted below 70%, amber from 70%, and red from 90%. Hover it to see every reported limit, including separate weekly model limits such as Claude Fable when the provider supplies them. Plan limits refresh with From e774eb96976d35ddc1acbb789c3a74eb8a6a1643 Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Mon, 24 Aug 2026 17:34:44 -0500 Subject: [PATCH 3/3] fix: harden provider usage indicator --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 27 ++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 40 ++++++++++++++++-- .../src/components/sidebar/SidebarChrome.tsx | 23 ++++------ .../sidebar/SidebarPlanUsage.logic.test.ts | 42 ++++++++++++++++++- .../sidebar/SidebarPlanUsage.logic.ts | 19 ++++++++- 5 files changed, 131 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index dbe1feef9c4d..90490db57cdd 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { " const message = JSON.parse(line);", ' if (message.type !== "control_request") return;', ' if (message.request?.subtype === "get_usage") {', + ' if (process.env.T3_PROBE_HANG_USAGE === "true") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -168,6 +169,32 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ], }); + const capabilitiesWithHungUsage = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + T3_PROBE_HANG_USAGE: "true", + }, + workspaceCwd, + 25, + ); + + assert.deepEqual(capabilitiesWithHungUsage, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + rateLimits: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { readonly args: ReadonlyArray; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 849494e2adaf..1116d9f76b0f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,3 +1,5 @@ +import * as NodeTimersPromises from "node:timers/promises"; + import { type ClaudeSettings, type ModelCapabilities, @@ -584,6 +586,27 @@ function apiProviderAuthMetadata( // account info. The previous 8s budget expired mid-init, so the probe returned // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +const USAGE_PROBE_TIMEOUT_MS = 2_000; +const CAPABILITIES_PROBE_RETURN_BUFFER_MS = 100; + +async function optionalPromiseWithin(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + void promise.catch(() => undefined); + return null; + } + + const timeoutAbort = new AbortController(); + try { + return await Promise.race([ + promise.catch(() => null), + NodeTimersPromises.setTimeout(timeoutMs, null, { signal: timeoutAbort.signal }).catch( + () => null, + ), + ]); + } finally { + timeoutAbort.abort(); + } +} /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -736,6 +759,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, + usageTimeoutMs = USAGE_PROBE_TIMEOUT_MS, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -745,6 +769,7 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const startedAt = performance.now(); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. @@ -768,9 +793,18 @@ const probeClaudeCapabilities = ( readonly apiProvider?: string; } | undefined; - const usage = await q - .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() - .catch(() => null); + const remainingProbeMs = + CAPABILITIES_PROBE_TIMEOUT_MS - + (performance.now() - startedAt) - + CAPABILITIES_PROBE_RETURN_BUFFER_MS; + const boundedUsageTimeoutMs = Math.min(usageTimeoutMs, remainingProbeMs); + const usage = + boundedUsageTimeoutMs > 0 + ? await optionalPromiseWithin( + q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + boundedUsageTimeoutMs, + ) + : null; return { email: account?.email, subscriptionType: account?.subscriptionType, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index d77391bbdddb..b9cea7777ed6 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -33,6 +33,7 @@ import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; import { collectSidebarPlanUsage, + formatPlanUsageReset, highestPlanUsagePercent, sidebarPlanUsageTone, } from "./SidebarPlanUsage.logic"; @@ -148,17 +149,6 @@ function SidebarUtilityItem({ ); } -function formatPlanUsageReset(resetsAt: string | null): string | null { - if (!resetsAt) return null; - const reset = new Date(resetsAt); - if (Number.isNaN(reset.getTime())) return null; - return new Intl.DateTimeFormat(undefined, { - weekday: "short", - hour: "numeric", - minute: "2-digit", - }).format(reset); -} - function SidebarPlanUsageItem({ onClick }: { onClick: () => void }) { const showPlanUsage = useClientSettings((settings) => settings.showPlanUsageInSidebar); const { environments } = useEnvironments(); @@ -167,6 +157,8 @@ function SidebarPlanUsageItem({ onClick }: { onClick: () => void }) { const roundedPercent = highestPercent === null ? null : Math.round(highestPercent); const tone = highestPercent === null ? "muted" : sidebarPlanUsageTone(highestPercent); const showEnvironment = environments.length > 1; + const systemLocale = + typeof window === "undefined" ? null : (window.desktopBridge?.getSystemLocale?.() ?? null); const label = roundedPercent === null ? "Usage" : `Usage, highest plan utilization ${roundedPercent}%`; @@ -178,9 +170,10 @@ function SidebarPlanUsageItem({ onClick }: { onClick: () => void }) { svg]:text-current!", - tone === "warning" && "text-warning hover:text-warning", - tone === "danger" && "text-destructive hover:text-destructive", + "w-auto", + tone === "warning" && "text-warning hover:text-warning [&>svg]:text-current!", + tone === "danger" && + "text-destructive hover:text-destructive [&>svg]:text-current!", )} onClick={onClick} size={roundedPercent === null ? "icon" : "default"} @@ -197,7 +190,7 @@ function SidebarPlanUsageItem({ onClick }: { onClick: () => void }) {
Plan usage
{entries.map((entry) => { - const reset = formatPlanUsageReset(entry.resetsAt); + const reset = formatPlanUsageReset(entry.resetsAt, systemLocale); return (
diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts index ea24c778cdbd..a47952b9c5e7 100644 --- a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { collectSidebarPlanUsage, + formatPlanUsageReset, highestPlanUsagePercent, sidebarPlanUsageTone, } from "./SidebarPlanUsage.logic.ts"; @@ -29,6 +30,7 @@ describe("sidebar plan usage", () => { it("collects every provider window with environment context", () => { const entries = collectSidebarPlanUsage([ { + environmentId: "environment-local", label: "Local", serverConfig: { providers: [ @@ -59,6 +61,40 @@ describe("sidebar plan usage", () => { expect(highestPlanUsagePercent(entries)).toBe(18); }); + it("uses stable environment ids in keys when labels collide", () => { + const usageProvider = provider({ + planUsage: { + checkedAt: "2026-08-24T18:01:00.000Z", + windows: [{ id: "seven_day", label: "All models", usedPercent: 10, resetsAt: null }], + }, + }); + const entries = collectSidebarPlanUsage([ + { + environmentId: "environment-a", + label: "Local", + serverConfig: { providers: [usageProvider] }, + }, + { + environmentId: "environment-b", + label: "Local", + serverConfig: { providers: [usageProvider] }, + }, + ]); + + expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); + }); + + it("formats reset times with the host locale", () => { + const resetsAt = "2026-08-24T22:00:00.000Z"; + expect(formatPlanUsageReset(resetsAt, "en-GB")).toBe( + new Intl.DateTimeFormat("en-GB", { + weekday: "short", + hour: "numeric", + minute: "2-digit", + }).format(new Date(resetsAt)), + ); + }); + it("uses the requested warning thresholds", () => { expect(sidebarPlanUsageTone(69.9)).toBe("muted"); expect(sidebarPlanUsageTone(70)).toBe("warning"); @@ -67,7 +103,11 @@ describe("sidebar plan usage", () => { }); it("reports no percentage before a provider has returned plan usage", () => { - expect(collectSidebarPlanUsage([{ label: "Local", serverConfig: null }])).toEqual([]); + expect( + collectSidebarPlanUsage([ + { environmentId: "environment-local", label: "Local", serverConfig: null }, + ]), + ).toEqual([]); expect(highestPlanUsagePercent([])).toBeNull(); }); }); diff --git a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts index 56f0091d6c3a..dbffd9b4ac1a 100644 --- a/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts +++ b/apps/web/src/components/sidebar/SidebarPlanUsage.logic.ts @@ -1,6 +1,9 @@ import type { ServerProvider } from "@t3tools/contracts"; +import { resolveTimestampLocale } from "../../timestampFormat"; + export interface SidebarPlanUsageEnvironment { + readonly environmentId: string; readonly label: string; readonly serverConfig: { readonly providers: ReadonlyArray; @@ -27,7 +30,7 @@ export function collectSidebarPlanUsage( const planUsage = provider.planUsage; if (!planUsage) return []; return planUsage.windows.map((window) => ({ - key: `${environment.label}:${provider.instanceId}:${window.id}`, + key: `${environment.environmentId}:${provider.instanceId}:${window.id}`, environmentLabel: environment.label, providerLabel: provider.displayName ?? provider.driver, windowLabel: window.label, @@ -39,6 +42,20 @@ export function collectSidebarPlanUsage( ); } +export function formatPlanUsageReset( + resetsAt: string | null, + systemLocale: string | null | undefined, +): string | null { + if (!resetsAt) return null; + const reset = new Date(resetsAt); + if (Number.isNaN(reset.getTime())) return null; + return new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { + weekday: "short", + hour: "numeric", + minute: "2-digit", + }).format(reset); +} + export function highestPlanUsagePercent( entries: ReadonlyArray, ): number | null {