From bf1f482187b3455a55691a05380ac56e934a3526 Mon Sep 17 00:00:00 2001 From: Aditya Mer <101453576+Aditya190803@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:11:36 +0530 Subject: [PATCH 1/2] feat(web): show current-provider usage in the chat box Add an opt-in setting that shows session and weekly quota for the thread's current provider in the context-window hover, instead of mixing every provider into the chat UI. Co-authored-by: Cursor --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 16 +++ .../chat/ComposerUsageMeter.logic.test.ts | 111 ++++++++++++++++++ .../chat/ComposerUsageMeter.logic.ts | 42 +++++++ .../chat/ComposerUsageMeter.test.tsx | 45 +++++++ .../components/chat/ComposerUsageMeter.tsx | 74 ++++++++++++ .../chat/ContextWindowMeter.test.tsx | 35 ++++++ .../components/chat/ContextWindowMeter.tsx | 17 ++- .../components/settings/SettingsPanels.tsx | 34 ++++++ .../settings/settingsSearch.test.ts | 4 + .../src/components/settings/settingsSearch.ts | 5 + docs/user/composer.md | 7 ++ docs/user/usage.md | 4 + packages/contracts/src/settings.test.ts | 9 ++ packages/contracts/src/settings.ts | 6 + 15 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ComposerUsageMeter.logic.test.ts create mode 100644 apps/web/src/components/chat/ComposerUsageMeter.logic.ts create mode 100644 apps/web/src/components/chat/ComposerUsageMeter.test.tsx create mode 100644 apps/web/src/components/chat/ComposerUsageMeter.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..f7ce3e957904 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -37,6 +37,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, planModeEnabled: false, showSkillsInSlashMenu: false, + showProviderUsageInComposer: true, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d0ca8a765ba..65d456b6154a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -125,6 +125,8 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; +import { ComposerUsageMeter } from "./ComposerUsageMeter"; +import { resolveComposerUsageMeter } from "./ComposerUsageMeter.logic"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -438,6 +440,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; + composerUsage: ReturnType; activeContextWindow: ContextWindowSnapshot | null; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; @@ -467,6 +470,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( }) { return ( <> + {props.composerUsage && !props.activeContextWindow ? ( + + ) : null} {props.activeContextWindow ? ( ) : null} {props.isPreparingWorktree ? ( @@ -927,6 +934,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const noProviderAvailable = selectedProviderEntry === undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); + const composerUsage = useMemo( + () => + resolveComposerUsageMeter({ + enabled: settings.showProviderUsageInComposer, + provider: selectedProviderEntry?.snapshot, + }), + [selectedProviderEntry, settings.showProviderUsageInComposer], + ); // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -3557,6 +3572,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showMobilePendingAnswerActions ? null : inlineStashBadge} & Pick, +): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + installed: true, + availability: "available", + auth: { status: "authenticated" }, + models: [], + ...overrides, + } as ServerProvider; +} + +describe("headlineUsageUsedPercent", () => { + it("uses the most constrained window so the ring tracks the tighter limit", () => { + expect( + headlineUsageUsedPercent([ + { kind: "session", label: "Session", usedPercent: 35 }, + { kind: "weekly", label: "Weekly", usedPercent: 82 }, + ]), + ).toBe(82); + }); +}); + +describe("resolveComposerUsageMeter", () => { + const codex = provider({ + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + usageLimits: usageLimits([ + { kind: "session", label: "Session", usedPercent: 20 }, + { kind: "weekly", label: "Weekly", usedPercent: 55 }, + ]), + }); + const claude = provider({ + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + displayName: "Claude Code", + usageLimits: usageLimits([{ kind: "session", label: "Session", usedPercent: 90 }]), + }); + + it("stays hidden until the setting is on", () => { + expect(resolveComposerUsageMeter({ enabled: false, provider: codex })).toBeNull(); + }); + + it("shows only the current provider's windows", () => { + expect(resolveComposerUsageMeter({ enabled: true, provider: codex })).toEqual({ + providerLabel: "Codex", + usageLimits: codex.usageLimits, + usedPercent: 55, + }); + expect(resolveComposerUsageMeter({ enabled: true, provider: claude })?.providerLabel).toBe( + "Claude Code", + ); + expect(resolveComposerUsageMeter({ enabled: true, provider: claude })?.usedPercent).toBe(90); + }); + + it("hides when the current provider has no usable quota snapshot", () => { + expect(resolveComposerUsageMeter({ enabled: true, provider: undefined })).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + provider: provider({ driver: ProviderDriverKind.make("codex"), usageLimits: undefined }), + }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + provider: provider({ + driver: ProviderDriverKind.make("opencode"), + usageLimits: usageLimits([{ kind: "session", label: "OpenCode Go", usedPercent: 10 }]), + }), + }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + provider: provider({ + driver: ProviderDriverKind.make("grok"), + auth: { status: "authenticated", label: "Free", type: "Free" }, + usageLimits: { + source: "grokStatusProbe", + available: false, + checkedAt: "2026-08-16T00:00:00.000Z", + reason: "Usage is only shown for paid tiers", + windows: [], + }, + }), + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/ComposerUsageMeter.logic.ts b/apps/web/src/components/chat/ComposerUsageMeter.logic.ts new file mode 100644 index 000000000000..7523b33ab1f3 --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.logic.ts @@ -0,0 +1,42 @@ +import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; + +import { + providerQuotaLabel, + providerQuotaNotice, + shouldShowProviderQuota, +} from "../usage/ProviderQuotaLimits"; + +export type ComposerUsageMeterModel = { + readonly providerLabel: string; + readonly usageLimits: ServerProviderUsageLimits; + readonly usedPercent: number; +}; + +export function headlineUsageUsedPercent(windows: ServerProviderUsageLimits["windows"]): number { + if (windows.length === 0) return 0; + return Math.max(...windows.map((window) => window.usedPercent)); +} + +/** + * Composer usage is opt-in and scoped to the thread's current provider. + * Unavailable, unpaid, or empty snapshots stay hidden so the chat box + * never renders an error state next to send. + */ +export function resolveComposerUsageMeter(input: { + readonly enabled: boolean; + readonly provider: ServerProvider | null | undefined; +}): ComposerUsageMeterModel | null { + if (!input.enabled) return null; + const provider = input.provider; + if (!provider || !shouldShowProviderQuota(provider)) return null; + if (providerQuotaNotice(provider) !== null) return null; + + const usageLimits = provider.usageLimits; + if (!usageLimits?.available || usageLimits.windows.length === 0) return null; + + return { + providerLabel: providerQuotaLabel(provider), + usageLimits, + usedPercent: headlineUsageUsedPercent(usageLimits.windows), + }; +} diff --git a/apps/web/src/components/chat/ComposerUsageMeter.test.tsx b/apps/web/src/components/chat/ComposerUsageMeter.test.tsx new file mode 100644 index 000000000000..516381a55d9e --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.test.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => children, + PopoverPopup: ({ children }: { children: ReactNode }) => children, + PopoverTrigger: ({ render }: { render: ReactNode }) =>
{render}
, +})); + +vi.mock("~/hooks/useSettings", () => ({ + usePrimarySettings: (selector?: (settings: { timestampFormat: "locale" }) => unknown) => + selector ? selector({ timestampFormat: "locale" }) : { timestampFormat: "locale" }, +})); + +import { ComposerUsageMeter } from "./ComposerUsageMeter"; + +describe("ComposerUsageMeter", () => { + it("labels the control with the current provider and keeps usage out of the context meter", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Codex usage 55% used"'); + expect(markup).toContain(">55%<"); + expect(markup).toContain("Usage"); + expect(markup).toContain("Session"); + expect(markup).toContain("Weekly"); + expect(markup).not.toContain("Context Window"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerUsageMeter.tsx b/apps/web/src/components/chat/ComposerUsageMeter.tsx new file mode 100644 index 000000000000..b4b8f64e7b6c --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.tsx @@ -0,0 +1,74 @@ +import { Button } from "../ui/button"; +import { cn } from "~/lib/utils"; +import { ProviderUsageBars } from "../usage/ProviderQuotaLimits"; +import type { ComposerUsageMeterModel } from "./ComposerUsageMeter.logic"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; + +function formatPercentage(value: number): string { + if (value < 10) { + return `${value.toFixed(1).replace(/\.0$/, "")}%`; + } + return `${Math.round(value)}%`; +} + +function usageMeterTextClass(percent: number): string { + if (percent >= 90) return "text-destructive"; + if (percent >= 70) return "text-warning"; + return "text-muted-foreground"; +} + +export function ComposerProviderUsageDetails(props: { usage: ComposerUsageMeterModel }) { + return ( +
+
+
Usage
+
{props.usage.providerLabel}
+
+ +
+ ); +} + +export function ComposerUsageMeter(props: { usage: ComposerUsageMeterModel }) { + const { usage } = props; + const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercent)); + const usedPercentage = formatPercentage(normalizedPercentage); + + return ( + + + + {usedPercentage} + + + } + /> + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.test.tsx b/apps/web/src/components/chat/ContextWindowMeter.test.tsx index 90617ca63af1..8e416a87b19b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.test.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.test.tsx @@ -3,6 +3,11 @@ import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; +vi.mock("~/hooks/useSettings", () => ({ + usePrimarySettings: (selector?: (settings: { timestampFormat: "locale" }) => unknown) => + selector ? selector({ timestampFormat: "locale" }) : { timestampFormat: "locale" }, +})); + import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow"; import { ContextWindowMeter } from "./ContextWindowMeter"; @@ -59,4 +64,34 @@ describe("ContextWindowMeter", () => { expect(markup).toContain(">Send or clear your draft before compacting<"); expect(markup).not.toContain('aria-label="Send or clear your draft before compacting"'); }); + + it("shows the current provider's usage bars without replacing context window stats", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Context Window"); + expect(markup).toContain("Usage"); + expect(markup).toContain("Codex"); + expect(markup).toContain("Session"); + expect(markup).toContain("Weekly"); + expect(markup).toContain("80% remaining"); + expect(markup).toContain("45% remaining"); + expect(markup).toContain('aria-label="Context window 10% used"'); + }); }); diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 93809c4f686c..65c2f0fc9781 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,8 @@ import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ComposerProviderUsageDetails } from "./ComposerUsageMeter"; +import type { ComposerUsageMeterModel } from "./ComposerUsageMeter.logic"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { Minimize2Icon } from "lucide-react"; @@ -20,8 +22,16 @@ export function ContextWindowMeter(props: { onCompact?: (() => void) | undefined; compactDisabled?: boolean | undefined; compactDisabledReason?: string | null | undefined; + providerUsage?: ComposerUsageMeterModel | null | undefined; }) { - const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props; + const { + usage, + modelDisplayName, + onCompact, + compactDisabled, + compactDisabledReason, + providerUsage, + } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -135,6 +145,11 @@ export function ContextWindowMeter(props: { {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)} ) : null} + {providerUsage ? ( +
+ +
+ ) : null} {onCompact ? ( <>