diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4c108b01d0c8..2abd56bf7780 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..b2e7a6e355fc --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.test.ts @@ -0,0 +1,219 @@ +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: "session:five_hour", + label: "Session", + remainingPercent: 60, + resetsAt: Date.parse(session.resetsAt), + }, + { + id: "weekly: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("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([]); + }); +}); 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..0b8c19d95430 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.logic.ts @@ -0,0 +1,67 @@ +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"; + +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; + /** 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.kind}:${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),