Skip to content
Open
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
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 @@ -187,6 +187,8 @@ import {
renderProviderTraitsMenuContent,
renderProviderTraitsPicker,
} from "./composerProviderState";
import { ComposerUsageMeter } from "./ComposerUsageMeter";
import { resolveComposerUsageMeter } from "./ComposerUsageMeter.logic";
import { ContextWindowMeter } from "./ContextWindowMeter";
import {
providerSupportsManualCompaction,
Expand Down Expand Up @@ -1043,6 +1045,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 @@ -1072,13 +1075,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}
<ComposerPrimaryActions
Expand Down Expand Up @@ -1613,6 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: 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 @@ -5607,6 +5623,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : null}
<ComposerFooterPrimaryActions
compact={isComposerResting || isComposerPrimaryActionsCompact}
composerUsage={composerUsage}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/ChatComposer.tsx:5626

When the desktop composer is resting without activeContextWindow, composerUsage adds a percent button beside the send controls, but the prompt row still reserves only pr-12 (or pr-20 with Attach), so the meter and send controls overlap the end of the one-line prompt for opted-in providers with quota data. Increase the resting prompt’s right padding to account for the usage meter, or keep the meter outside that absolutely positioned footer.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 5626:

When the desktop composer is resting without `activeContextWindow`, `composerUsage` adds a percent button beside the send controls, but the prompt row still reserves only `pr-12` (or `pr-20` with Attach), so the meter and send controls overlap the end of the one-line prompt for opted-in providers with quota data. Increase the resting prompt’s right padding to account for the usage meter, or keep the meter outside that absolutely positioned footer.

activeContextWindow={
settings.contextWindowMeterEnabled ? activeContextWindow : null
}
Expand Down
134 changes: 134 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,134 @@
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 {
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([
{ 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();
});
});
57 changes: 57 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.logic.ts
Original file line number Diff line number Diff line change
@@ -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<ServerProvider, "driver" | "auth">): 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),
};
}
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/ComposerUsageMeter.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <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: {
checkedAt: "2026-08-16T00:00:00.000Z",
windows: [
{ id: "primary", kind: "session", label: "Session", usedPercent: 20 },
{ id: "secondary", 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");
});
});
Loading
Loading