diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 10898e311cef..ef12f81bc843 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -187,6 +187,8 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; +import { ComposerUsageMeter } from "./ComposerUsageMeter"; +import { resolveComposerUsageMeter } from "./ComposerUsageMeter.logic"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, @@ -1043,6 +1045,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; @@ -1072,6 +1075,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( }) { return ( <> + {props.composerUsage && !props.activeContextWindow ? ( + + ) : null} {props.activeContextWindow ? ( ) : null} + 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. @@ -5607,6 +5623,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} & 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([ + { id: "primary", kind: "session", label: "Session", usedPercent: 35 }, + { id: "secondary", kind: "weekly", label: "Weekly", usedPercent: 82 }, + ]), + ).toBe(82); + }); +}); + +describe("resolveComposerUsageMeter", () => { + const codex = provider({ + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + usageLimits: usageLimits([ + { id: "primary", kind: "session", label: "Session", usedPercent: 20 }, + { id: "secondary", kind: "weekly", label: "Weekly", usedPercent: 55 }, + ]), + }); + const claude = provider({ + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + displayName: "Claude Code", + usageLimits: usageLimits([ + { id: "primary", 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("codex"), + enabled: false, + usageLimits: usageLimits([ + { id: "primary", kind: "session", label: "Session", usedPercent: 10 }, + ]), + }), + }), + ).toBeNull(); + expect( + resolveComposerUsageMeter({ + enabled: true, + hasStartedTurn: true, + provider: provider({ + driver: ProviderDriverKind.make("grok"), + auth: { status: "authenticated", label: "Free", type: "Free" }, + usageLimits: { + checkedAt: "2026-08-16T00:00:00.000Z", + windows: [], + unavailable: { + reason: "unsupported", + message: "Usage is only shown for paid tiers", + }, + }, + }), + }), + ).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..d3bf950b977b --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.logic.ts @@ -0,0 +1,57 @@ +import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; + +import { getDriverOption } from "../settings/providerDriverMeta"; + +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)); +} + +export function composerUsageProviderLabel(provider: ServerProvider): string { + return ( + provider.displayName?.trim() || + getDriverOption(provider.driver)?.label || + String(provider.driver) + ); +} + +function isGrokFreeTier(provider: Pick): boolean { + if (provider.driver !== "grok") return false; + const tier = (provider.auth.label ?? provider.auth.type ?? "").trim().toLowerCase(); + return tier === "free"; +} + +/** + * Chat-box usage is opt-in, scoped to the thread's current provider, and + * hidden until the thread has started a turn. Missing, failed, or empty + * snapshots stay hidden so the chat box never renders an error state next + * to send. Any provider with usable windows shows. + */ +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) return null; + if (!provider.enabled || !provider.installed || provider.availability === "unavailable") { + return null; + } + if (isGrokFreeTier(provider)) return null; + + const usageLimits = provider.usageLimits; + if (!usageLimits || usageLimits.unavailable || usageLimits.windows.length === 0) return null; + + return { + providerLabel: composerUsageProviderLabel(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..1fffee3f898c --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.test.tsx @@ -0,0 +1,43 @@ +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..26e470e2c109 --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageMeter.tsx @@ -0,0 +1,155 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { Button } from "../ui/button"; +import { cn } from "~/lib/utils"; +import { usePrimarySettings } from "~/hooks/useSettings"; +import { formatUpcomingTimestamp } from "~/timestampFormat"; +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"; +} + +function usageBarClass(percent: number): string { + if (percent >= 90) return "bg-destructive"; + if (percent >= 70) return "bg-warning"; + return "bg-foreground"; +} + +const utcDateFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +/** + * Reset instant for one quota window. Date-only UTC sources (Cursor's panel + * is date-only, stored as UTC midnight) render as a plain date — including + * a clock would invent a local time like 5:30 AM. + */ +function formatWindowReset( + resetsAt: string | undefined, + timestampFormat: TimestampFormat, +): string | null { + if (!resetsAt) return null; + if (/T00:00:00(?:\.000)?Z$/.test(resetsAt)) { + const date = new Date(resetsAt); + return Number.isNaN(date.getTime()) ? null : utcDateFormatter.format(date); + } + const formatted = formatUpcomingTimestamp(resetsAt, timestampFormat); + return formatted.length > 0 ? formatted : null; +} + +export function ComposerUsageBars(props: { + readonly usageLimits: ServerProviderUsageLimits; + readonly className?: string; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const { usageLimits } = props; + + if (usageLimits.unavailable || usageLimits.windows.length === 0) return null; + + return ( +
+ {usageLimits.windows.map((window) => { + // The bar width and the "% remaining" label must derive from the same + // rounded number. Deriving the label from a rounded value and the bar + // from the raw one makes 99.6% read as "0% remaining" next to a bar + // that is visibly not full. + const roundedPercent = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remainingPercent = 100 - roundedPercent; + const reset = formatWindowReset(window.resetsAt, timestampFormat); + + return ( +
+
+ {window.label} + {remainingPercent}% remaining +
+
+
+
+ {reset ?
Resets {reset}
: null} +
+ ); + })} +
+ ); +} + +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..f5aa4b2b0eee 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,32 @@ 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 a11f400c0627..604e6295e3a6 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"; import { composerFloatingLayerProps } from "./composerEventScope"; @@ -21,8 +23,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; @@ -137,6 +147,11 @@ export function ContextWindowMeter(props: { {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
) : null} + {providerUsage ? ( +
+ +
+ ) : null} {onCompact ? ( <>