Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const clientSettings: ClientSettings = {
glassOpacity: 80,
planModeEnabled: false,
showSkillsInSlashMenu: false,
showProviderUsageInComposer: true,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -438,6 +440,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop

const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
compact: boolean;
composerUsage: ReturnType<typeof resolveComposerUsageMeter>;
activeContextWindow: ContextWindowSnapshot | null;
activeThreadModelDisplayName: string | null;
isPreparingWorktree: boolean;
Expand Down Expand Up @@ -467,13 +470,17 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
}) {
return (
<>
{props.composerUsage && !props.activeContextWindow ? (
<ComposerUsageMeter usage={props.composerUsage} />
) : null}
{props.activeContextWindow ? (
<ContextWindowMeter
usage={props.activeContextWindow}
modelDisplayName={props.activeThreadModelDisplayName}
onCompact={props.onCompactContext}
compactDisabled={props.compactDisabled}
compactDisabledReason={props.compactDisabledReason}
providerUsage={props.composerUsage}
/>
) : null}
{props.isPreparingWorktree ? (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3557,6 +3573,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
{showMobilePendingAnswerActions ? null : inlineStashBadge}
<ComposerFooterPrimaryActions
compact={isComposerPrimaryActionsCompact}
composerUsage={composerUsage}
activeContextWindow={activeContextWindow}
activeThreadModelDisplayName={activeThreadModelDisplayName}
pendingAction={pendingPrimaryAction}
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerProviderUsageLimits,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { headlineUsageUsedPercent, resolveComposerUsageMeter } from "./ComposerUsageMeter.logic";

function usageLimits(windows: ServerProviderUsageLimits["windows"]): ServerProviderUsageLimits {
return {
source: "codexAppServer",
available: true,
checkedAt: "2026-08-16T00:00:00.000Z",
windows,
};
}

function provider(
overrides: Partial<ServerProvider> & Pick<ServerProvider, "driver">,
): 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();
});
});
44 changes: 44 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.logic.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
45 changes: 45 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{render}</div>,
}));

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(
<ComposerUsageMeter
usage={{
providerLabel: "Codex",
usedPercent: 55,
usageLimits: {
source: "codexAppServer",
available: true,
checkedAt: "2026-08-16T00:00:00.000Z",
windows: [
{ kind: "session", label: "Session", usedPercent: 20 },
{ kind: "weekly", label: "Weekly", usedPercent: 55 },
],
},
}}
/>,
);

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");
});
});
74 changes: 74 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-muted-foreground text-xs">Usage</div>
<div className="truncate text-secondary-label text-[11px]">{props.usage.providerLabel}</div>
</div>
<ProviderUsageBars usageLimits={props.usage.usageLimits} className="gap-2" />
</div>
);
}

export function ComposerUsageMeter(props: { usage: ComposerUsageMeterModel }) {
const { usage } = props;
const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercent));
const usedPercentage = formatPercentage(normalizedPercentage);

return (
<Popover>
<PopoverTrigger
openOnHover
delay={150}
closeDelay={0}
render={
<Button
size="sm"
variant="ghost-muted"
className="h-7 rounded-full px-2 hover:text-muted-foreground data-pressed:text-muted-foreground"
aria-label={`${usage.providerLabel} usage ${usedPercentage} used`}
>
<span
className={cn(
"text-[11px] tabular-nums font-medium",
usageMeterTextClass(normalizedPercentage),
)}
>
{usedPercentage}
</span>
</Button>
}
/>
<PopoverPopup
tooltipStyle
side="top"
align="end"
viewportClassName="p-0"
className="w-64 max-w-none text-left whitespace-normal"
>
<div className="flex flex-col gap-2 p-[var(--floating-content-inset)]">
<ComposerProviderUsageDetails usage={usage} />
</div>
</PopoverPopup>
</Popover>
);
}
Loading
Loading