From a8196ace509ab60075eec0d18296b161f029426d Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:19:19 +0200 Subject: [PATCH 1/3] feat(web): show remaining usage limits in the sidebar footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users with Codex and Claude subscriptions had no way to see how much quota was left without opening Usage → Limits. An opt-in pill above the sidebar's utility row now shows, per provider, the share left in its most constrained window, pooled across accounts the same way the Limits tab pools them. Hover lists every window with its reset; select it to open Usage → Limits. The pill reads the provider snapshots every client already holds, so it costs no request and never refreshes on its own. The Usage page now subscribes to its own preference saves so the pill can switch it to Limits while it is already open. --- .../components/settings/SettingsPanels.tsx | 18 ++ .../src/components/settings/settingsSearch.ts | 6 + .../src/components/sidebar/SidebarChrome.tsx | 2 + .../sidebar/SidebarUsageLimits.logic.test.ts | 183 ++++++++++++++++++ .../sidebar/SidebarUsageLimits.logic.ts | 66 +++++++ .../sidebar/SidebarUsageLimitsPill.tsx | 130 +++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 24 ++- .../usage/usagePagePreferences.test.ts | 22 ++- .../components/usage/usagePagePreferences.ts | 16 ++ docs/user/usage.md | 6 + packages/contracts/src/settings.test.ts | 10 + packages/contracts/src/settings.ts | 5 + 12 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts create mode 100644 apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts create mode 100644 apps/web/src/components/sidebar/SidebarUsageLimitsPill.tsx diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f51c14c4f395..35d136266eec 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -523,6 +523,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarUsageLimitsEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarUsageLimitsEnabled + ? ["Usage limits in sidebar"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -628,6 +631,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, + settings.sidebarUsageLimitsEnabled, settings.showSkillsInSlashMenu, settings.timestampFormat, settings.wordWrap, @@ -714,6 +718,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarUsageLimitsEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarUsageLimitsEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, @@ -2111,6 +2116,19 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ sidebarUsageLimitsEnabled: Boolean(checked) }) + } + aria-label="Usage limits in sidebar" + /> + } + /> {supportsAutoSettlement ? ( <> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..0cf1a2b8ed2a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -162,6 +162,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["combine matching repositories environments sidebar"], }, + { + id: "sidebar-usage-limits", + title: "Usage limits in sidebar", + to: "/settings/general", + searchTerms: ["quota remaining rate limit codex claude footer subscription"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4f115a751422..92738bc5abed 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -33,6 +33,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; +import { SidebarUsageLimitsPill } from "./SidebarUsageLimitsPill"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -226,6 +227,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + ); diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts new file mode 100644 index 000000000000..867e2498b2c7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts @@ -0,0 +1,183 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { collectSidebarLimits, limitTone } from "./SidebarUsageLimits.logic"; + +const now = Date.parse("2026-09-03T12:00:00.000Z"); + +const session = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-03T14:00:00.000Z", +} as const; + +const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 85, + windowDurationMins: 10_080, + resetsAt: "2026-09-06T15:30:00.000Z", +} as const; + +function provider(overrides: Partial): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-03T11:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +function presentations( + ...environments: ReadonlyArray +) { + return new Map( + environments.map(([label, providers], index) => [ + EnvironmentId.make(`env-${index + 1}`), + { entry: { target: { label } }, serverConfig: { providers } }, + ]), + ); +} + +describe("limitTone", () => { + it("steps from neutral to low to critical as the quota drains", () => { + expect(limitTone(100)).toBe("ok"); + expect(limitTone(26)).toBe("ok"); + expect(limitTone(25)).toBe("low"); + expect(limitTone(11)).toBe("low"); + expect(limitTone(10)).toBe("critical"); + expect(limitTone(0)).toBe("critical"); + }); +}); + +describe("collectSidebarLimits", () => { + it("shows the tightest window per provider and keeps every window for the details", () => { + const [codex] = collectSidebarLimits( + presentations([ + "Laptop", + [ + provider({ + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [session, weekly] }, + }), + ], + ]), + now, + ); + expect(codex).toMatchObject({ + driver: "codex", + accountCount: 1, + remainingPercent: 15, + tone: "low", + }); + expect(codex?.windows).toEqual([ + { + id: "five_hour", + label: "Session", + remainingPercent: 60, + resetsAt: Date.parse(session.resetsAt), + }, + { + id: "seven_day", + label: "Weekly", + remainingPercent: 15, + resetsAt: Date.parse(weekly.resetsAt), + }, + ]); + }); + + it("pools accounts across environments and reports the soonest reset", () => { + const views = collectSidebarLimits( + presentations( + [ + "Laptop", + [ + provider({ + auth: { status: "authenticated", email: "a@example.com" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...session, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" }], + }, + }), + ], + ], + [ + "Desktop", + [ + provider({ + auth: { status: "authenticated", email: "b@example.com" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...session, usedPercent: 10 }], + }, + }), + ], + ], + ), + now, + ); + expect(views).toHaveLength(1); + expect(views[0]).toMatchObject({ accountCount: 2, remainingPercent: 50, tone: "ok" }); + expect(views[0]?.windows[0]?.resetsAt).toBe(Date.parse("2026-09-03T13:00:00.000Z")); + }); + + it("orders providers by driver and leaves out ones with nothing usable", () => { + const views = collectSidebarLimits( + presentations([ + "Laptop", + [ + provider({ + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [session] }, + }), + provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...weekly, usedPercent: 95 }], + }, + }), + provider({ + instanceId: ProviderInstanceId.make("claude-api"), + driver: ProviderDriverKind.make("claudeAgent"), + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + unavailable: { reason: "unsupported" }, + }, + }), + provider({ + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + }), + ], + ]), + now, + ); + expect(views.map((view) => [view.driver, view.remainingPercent, view.tone])).toEqual([ + ["claudeAgent", 5, "critical"], + ["codex", 60, "ok"], + ]); + }); + + it("is empty when no provider reports limits", () => { + expect(collectSidebarLimits(presentations(["Laptop", [provider({})]]), now)).toEqual([]); + expect(collectSidebarLimits(new Map(), now)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts new file mode 100644 index 000000000000..da6c7608f5bf --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts @@ -0,0 +1,66 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { collectLimitAccounts, collectLimitPools } from "@t3tools/shared/usageLimits"; + +/** Neutral until a quarter is left, then warning, then destructive at a tenth. */ +export type SidebarLimitTone = "ok" | "low" | "critical"; + +export const LOW_REMAINING_PERCENT = 25; +export const CRITICAL_REMAINING_PERCENT = 10; + +export interface SidebarLimitWindow { + readonly id: string; + readonly label: string; + readonly remainingPercent: number; + /** Soonest reset across the pooled accounts, epoch millis, or null when none reports one. */ + readonly resetsAt: number | null; +} + +export interface SidebarLimitView { + readonly driver: ServerProvider["driver"]; + /** Distinct accounts pooled into these numbers. */ + readonly accountCount: number; + /** Quota left in the tightest window, which is what decides whether the next turn runs. */ + readonly remainingPercent: number; + readonly tone: SidebarLimitTone; + /** Every pooled window, ordered as Usage → Limits orders them. */ + readonly windows: readonly SidebarLimitWindow[]; +} + +export function limitTone(remainingPercent: number): SidebarLimitTone { + if (remainingPercent <= CRITICAL_REMAINING_PERCENT) return "critical"; + if (remainingPercent <= LOW_REMAINING_PERCENT) return "low"; + return "ok"; +} + +/** + * One entry per provider with usable limits, pooled across accounts and + * environments exactly as Usage → Limits pools them, reduced to the number + * the sidebar has room for: the share left in the most constrained window. + * Providers sort by driver so the pill keeps a stable reading order across + * sessions. `now` only feeds the pace maths the pill does not show. + */ +export function collectSidebarLimits( + presentations: Parameters[0], + now: number, +): readonly SidebarLimitView[] { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + return pools + .filter((pool) => pool.windows.length > 0) + .map((pool) => { + const windows = pool.windows.map((window) => ({ + id: window.id, + label: window.label, + remainingPercent: window.remainingPercent, + resetsAt: window.resets[0]?.at ?? null, + })); + const remainingPercent = Math.min(...windows.map((window) => window.remainingPercent)); + return { + driver: pool.driver, + accountCount: pool.accounts.length, + remainingPercent, + tone: limitTone(remainingPercent), + windows, + }; + }) + .sort((left, right) => left.driver.localeCompare(right.driver)); +} diff --git a/apps/web/src/components/sidebar/SidebarUsageLimitsPill.tsx b/apps/web/src/components/sidebar/SidebarUsageLimitsPill.tsx new file mode 100644 index 000000000000..feb532141f03 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimitsPill.tsx @@ -0,0 +1,130 @@ +import { useAtomValue } from "@effect/atom-react"; +import { formatDuration } from "@t3tools/shared/usageLimits"; +import { useNavigate } from "@tanstack/react-router"; +import { GaugeIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { useClientSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { environmentPresentations } from "../../state/presentation"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { useSidebar } from "../ui/sidebar"; +import { readUsagePagePreferences, saveUsagePagePreferences } from "../usage/usagePagePreferences"; +import { + collectSidebarLimits, + type SidebarLimitTone, + type SidebarLimitView, +} from "./SidebarUsageLimits.logic"; + +const TONE_STYLES: Record = { + ok: "text-sidebar-foreground", + low: "text-warning", + critical: "text-destructive", +}; + +/** + * Remaining subscription quota per provider, one segment each, sitting above + * the sidebar's utility row. Reads the provider snapshots every client already + * holds, so it costs no request; a segment opens Usage → Limits for the rest. + */ +export function SidebarUsageLimitsPill() { + const enabled = useClientSettings((settings) => settings.sidebarUsageLimitsEnabled); + return enabled ? : null; +} + +function SidebarUsageLimitsPillContent() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount: the clock only feeds pace, which the pill does + // not show, so re-pooling on every tick would repaint the sidebar for nothing. + const [mountedAt] = useState(() => Date.now()); + const limits = useMemo( + () => collectSidebarLimits(presentations, mountedAt), + [mountedAt, presentations], + ); + const openLimits = useCallback(() => { + if (isMobile) setOpenMobile(false); + saveUsagePagePreferences({ ...readUsagePagePreferences(), metric: "limits" }); + void navigate({ to: "/usage" }); + }, [isMobile, navigate, setOpenMobile]); + + if (limits.length === 0) return null; + + return ( +
+ {limits.map((limit) => ( + + ))} +
+ ); +} + +function SidebarLimitSegment({ + limit, + onClick, +}: { + readonly limit: SidebarLimitView; + readonly onClick: () => void; +}) { + const option = getDriverOption(limit.driver); + const Mark = option?.icon ?? GaugeIcon; + const label = option?.label ?? String(limit.driver); + return ( + + + } + > + + {limit.remainingPercent}% + + + + + + ); +} + +/** Mounted when the tooltip opens, so every countdown is fresh at that moment. */ +function SidebarLimitDetails({ + label, + limit, +}: { + readonly label: string; + readonly limit: SidebarLimitView; +}) { + const [now] = useState(() => Date.now()); + return ( +
+ + {label} + {limit.accountCount > 1 ? ` · ${limit.accountCount} accounts` : ""} + + {limit.windows.map((window) => ( + + {window.label}: {window.remainingPercent}% left + {window.resetsAt === null + ? "" + : window.resetsAt <= now + ? " · resets now" + : ` · resets in ${formatDuration(window.resetsAt - now)}`} + + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..65ef6cdd7aba 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,7 +11,7 @@ import { CircleDashedIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, useEffect } from "react"; import { isCompatibleUsageContractVersion, @@ -65,6 +65,7 @@ import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usa import { readUsagePagePreferences, saveUsagePagePreferences, + subscribeUsagePagePreferences, type UsagePagePreferences, } from "./usagePagePreferences"; @@ -100,6 +101,27 @@ export function UsagePage() { preferences.windowDays === 1 ? "hour" : "day", ), })); + // Something else may pick the metric while this page is mounted, such as + // the sidebar's limits pill; its save lands here instead of being lost. + useEffect( + () => + subscribeUsagePagePreferences((next) => { + setPreferences(next); + setWindowSelection((current) => + current.days === next.windowDays + ? current + : { + days: next.windowDays, + window: makeWindow( + next.windowDays, + undefined, + next.windowDays === 1 ? "hour" : "day", + ), + }, + ); + }), + [], + ); const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts index dcdde98fa247..d50dd012baf5 100644 --- a/apps/web/src/components/usage/usagePagePreferences.test.ts +++ b/apps/web/src/components/usage/usagePagePreferences.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences"; +import { + readUsagePagePreferences, + saveUsagePagePreferences, + subscribeUsagePagePreferences, +} from "./usagePagePreferences"; const key = "t3code:usage-page-preferences:v1"; let values: Map; @@ -58,6 +62,22 @@ describe("Usage page preferences", () => { expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 }); }); + it("tells subscribers about every save until they unsubscribe", () => { + const seen: unknown[] = []; + const unsubscribe = subscribeUsagePagePreferences((preferences) => seen.push(preferences)); + saveUsagePagePreferences({ metric: "limits", windowDays: 7 }); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + saveUsagePagePreferences({ metric: "tokens", windowDays: 1 }); + unsubscribe(); + saveUsagePagePreferences({ metric: "cost", windowDays: 30 }); + expect(seen).toEqual([ + { metric: "limits", windowDays: 7 }, + { metric: "tokens", windowDays: 1 }, + ]); + }); + it("contains failures when the browser blocks storage access", () => { vi.stubGlobal("window", { get localStorage() { diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index ddd6d5341cc0..5504659ca099 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -23,10 +23,26 @@ export function readUsagePagePreferences(): UsagePagePreferences { } } +type Listener = (preferences: UsagePagePreferences) => void; +const listeners = new Set(); + +/** + * Hear every save, wherever it came from, so a mounted Usage page follows a + * change made outside it, such as the sidebar opening Limits. Storage is only + * persistence: listeners run even when the write fails. + */ +export function subscribeUsagePagePreferences(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + export function saveUsagePagePreferences(preferences: UsagePagePreferences): void { try { setLocalStorageItem(STORAGE_KEY, preferences, UsagePagePreferencesSchema); } catch (error) { console.error("Could not save Usage page preferences.", error); } + for (const listener of listeners) listener(preferences); } diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..2ed957b0de29 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -56,6 +56,12 @@ Filter with the environment dropdown to see what a single machine has. If a window looks stale, refresh Limits to re-check every provider and hub. +To keep the tightest window in view while you work, turn on **Settings → General → Usage limits in +sidebar**. The sidebar footer then shows, per provider, how much of its most constrained window is +left, pooled across accounts the same way **Usage → Limits** pools them. The figure turns amber +with a quarter left and red with a tenth. Hover for every window and its reset; select it to open +**Usage → Limits**. It updates from the same snapshots and never refreshes on its own. + Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7d3cd2ceafe7..4cf8506bca89 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -393,6 +393,16 @@ describe("ClientSettings sidebar", () => { ); }); + it("keeps the sidebar usage limits pill off until opted in", () => { + expect(decodeClientSettings({}).sidebarUsageLimitsEnabled).toBe(false); + expect( + decodeClientSettings({ sidebarUsageLimitsEnabled: true }).sidebarUsageLimitsEnabled, + ).toBe(true); + expect( + decodeClientSettingsPatch({ sidebarUsageLimitsEnabled: true }).sidebarUsageLimitsEnabled, + ).toBe(true); + }); + it("keeps unpin confirmation opt-in and patchable", () => { expect(decodeClientSettings({}).confirmThreadUnpin).toBe(false); expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..3b4b0e770bf9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -413,6 +413,10 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Remaining subscription quota per provider in the sidebar footer, read + // from the same provider snapshots as Usage → Limits. Opt-in: it is extra + // chrome for everyone whose providers report limits. + sidebarUsageLimitsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -1355,6 +1359,7 @@ export const ClientSettingsPatch = Schema.Struct({ proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + sidebarUsageLimitsEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From c515901e869d3b596ebcf0d305c3991d5e40d80e Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:33:55 +0200 Subject: [PATCH 2/3] fix(web): key pooled sidebar limit windows by kind and drop unused exports Codex reports the same window id as a session window on one plan and a monthly one on another, so the tooltip rows need the kind in their key. The tone thresholds were exported without a consumer, which knip rejects. --- .../components/sidebar/SidebarUsageLimits.logic.test.ts | 4 ++-- .../web/src/components/sidebar/SidebarUsageLimits.logic.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts index 867e2498b2c7..556562b2e501 100644 --- a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts @@ -88,13 +88,13 @@ describe("collectSidebarLimits", () => { }); expect(codex?.windows).toEqual([ { - id: "five_hour", + id: "session:five_hour", label: "Session", remainingPercent: 60, resetsAt: Date.parse(session.resetsAt), }, { - id: "seven_day", + id: "weekly:seven_day", label: "Weekly", remainingPercent: 15, resetsAt: Date.parse(weekly.resetsAt), diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts index da6c7608f5bf..0b8c19d95430 100644 --- a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts @@ -4,10 +4,11 @@ import { collectLimitAccounts, collectLimitPools } from "@t3tools/shared/usageLi /** Neutral until a quarter is left, then warning, then destructive at a tenth. */ export type SidebarLimitTone = "ok" | "low" | "critical"; -export const LOW_REMAINING_PERCENT = 25; -export const CRITICAL_REMAINING_PERCENT = 10; +const LOW_REMAINING_PERCENT = 25; +const CRITICAL_REMAINING_PERCENT = 10; export interface SidebarLimitWindow { + /** `kind:id`, since Codex reuses `primary` for a session window on one plan and a monthly one on another. */ readonly id: string; readonly label: string; readonly remainingPercent: number; @@ -48,7 +49,7 @@ export function collectSidebarLimits( .filter((pool) => pool.windows.length > 0) .map((pool) => { const windows = pool.windows.map((window) => ({ - id: window.id, + id: `${window.kind}:${window.id}`, label: window.label, remainingPercent: window.remainingPercent, resetsAt: window.resets[0]?.at ?? null, From 119b1966baf519a579f39228bd33c16b3db7eb96 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:42:35 +0200 Subject: [PATCH 3/3] test(web): cover a provider reusing a window id across kinds --- .../sidebar/SidebarUsageLimits.logic.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts index 556562b2e501..b2e7a6e355fc 100644 --- a/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts @@ -176,6 +176,42 @@ describe("collectSidebarLimits", () => { ]); }); + it("keeps a session and a monthly window apart when the provider reuses their id", () => { + const [codex] = collectSidebarLimits( + presentations( + [ + "Laptop", + [ + provider({ + auth: { status: "authenticated", email: "paid@example.com" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...session, id: "primary", usedPercent: 30 }], + }, + }), + ], + ], + [ + "Desktop", + [ + provider({ + auth: { status: "authenticated", email: "free@example.com" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ id: "primary", kind: "monthly", label: "Monthly", usedPercent: 70 }], + }, + }), + ], + ], + ), + now, + ); + expect(codex?.windows.map((window) => [window.id, window.remainingPercent])).toEqual([ + ["session:primary", 70], + ["monthly:primary", 30], + ]); + }); + it("is empty when no provider reports limits", () => { expect(collectSidebarLimits(presentations(["Laptop", [provider({})]]), now)).toEqual([]); expect(collectSidebarLimits(new Map(), now)).toEqual([]);