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..0529cbe0342c 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,15 @@ 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, + hasStartedTurn: activeThread?.latestTurn != null || phase === "running", + provider: selectedProviderEntry?.snapshot, + }), + [activeThread?.latestTurn, phase, 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 +3573,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, hasStartedTurn: true, provider: codex }), + ).toBeNull(); + }); + + it("stays hidden until the thread has started a turn", () => { + expect( + resolveComposerUsageMeter({ enabled: true, hasStartedTurn: false, provider: codex }), + ).toBeNull(); + }); + + it("shows only the current provider's windows", () => { + expect( + resolveComposerUsageMeter({ enabled: true, hasStartedTurn: true, provider: codex }), + ).toEqual({ + providerLabel: "Codex", + usageLimits: codex.usageLimits, + usedPercent: 55, + }); + expect( + resolveComposerUsageMeter({ enabled: true, hasStartedTurn: true, provider: claude }) + ?.providerLabel, + ).toBe("Claude Code"); + expect( + resolveComposerUsageMeter({ enabled: true, hasStartedTurn: true, provider: claude }) + ?.usedPercent, + ).toBe(90); + }); + + it("hides when the current provider has no usable quota snapshot", () => { + expect( + resolveComposerUsageMeter({ enabled: true, hasStartedTurn: true, provider: undefined }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + hasStartedTurn: true, + provider: provider({ driver: ProviderDriverKind.make("codex"), usageLimits: undefined }), + }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + hasStartedTurn: true, + provider: provider({ + driver: ProviderDriverKind.make("opencode"), + usageLimits: usageLimits([{ kind: "session", label: "OpenCode Go", usedPercent: 10 }]), + }), + }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + hasStartedTurn: 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..80446faedf4d --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.logic.ts @@ -0,0 +1,44 @@ +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)); +} + +/** + * Chat-box usage is opt-in, scoped to the thread's current provider, and + * hidden until the thread has started a turn. 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 hasStartedTurn: boolean; + readonly provider: ServerProvider | null | undefined; +}): ComposerUsageMeterModel | null { + if (!input.enabled || !input.hasStartedTurn) 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 ? ( <>