Skip to content
Merged
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
38 changes: 34 additions & 4 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,17 +340,47 @@ export function ResetCredits({
*/
export function UsageLimitsSection({
selectedEnvironmentIds,
hiddenProviders,
now,
cursorPrompt,
}: {
readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
readonly now: number;
readonly cursorPrompt?: ReactNode;
}) {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const selected =
selectedEnvironmentIds === null
? presentations
: new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
const hiddenDrivers = new Set<ServerProvider["driver"]>(
[...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind),
);
const isVisible = (driver: ServerProvider["driver"]) => !hiddenDrivers.has(driver);
// Hidden drivers leave both native accounts and hub accounts, so their
// bars, notices, and external links all drop out together.
const selected = new Map(
[...presentations].flatMap(([id, presentation]) => {
if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(id)) return [];
const config = presentation.serverConfig;
if (hiddenDrivers.size === 0 || config === null) return [[id, presentation] as const];
return [
[
id,
{
...presentation,
serverConfig: {
...config,
providers: config.providers.filter((provider) => isVisible(provider.driver)),
// A hub left with no visible accounts is dropped, not reported as empty.
usageLimitSources: config.usageLimitSources?.flatMap((source) => {
const accounts = source.accounts.filter((account) => isVisible(account.driver));
return accounts.length === 0 && source.accounts.length > 0
? []
: [{ ...source, accounts }];
}),
},
},
] as const,
];
}),
);
return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
}
1 change: 1 addition & 0 deletions apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ vi.mock("./usageProviders", async (importOriginal) => {
const actual = await importOriginal<typeof import("./usageProviders")>();
return {
...actual,
PROVIDER_ORDER: ["codex", "claude"],
PROVIDER_PRESENTATION: {
codex: { color: "white", label: "Codex", mark: "span" },
claude: { color: "orange", label: "Claude Code", mark: "span" },
Expand Down
96 changes: 87 additions & 9 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,29 @@ export function UsagePage() {
const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
useState<ReadonlySet<EnvironmentId> | null>(null);
const hiddenProviders = useMemo(
() => new Set(preferences.hiddenProviders),
[preferences.hiddenProviders],
);
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
window,
selectedEnvironmentIds,
hiddenProviders,
);
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
const cursorAccessEnvironments = hiddenProviders.has("cursor")
? []
: cursorKeychainAccessEnvironments(selectedEnvironments);
const sourceMessages = [
...new Set(
selectedEnvironments.flatMap(
(environment) =>
environment.summary?.sources.flatMap((source) =>
source.message &&
!source.action &&
!hiddenProviders.has(source.fingerprint.provider) &&
(source.status === "partial" ||
source.status === "failed" ||
source.fingerprint.provider === "cursor")
Expand Down Expand Up @@ -217,21 +225,22 @@ export function UsagePage() {
);
const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;

const selectWindow = (days: number) => {
if (!isUsageWindowDays(days)) return;
const nextPreferences = { metric, windowDays: days };
const updatePreferences = (patch: Partial<UsagePagePreferences>) => {
const nextPreferences = { ...preferences, ...patch };
setPreferences(nextPreferences);
saveUsagePagePreferences(nextPreferences);
};
const selectWindow = (days: number) => {
if (!isUsageWindowDays(days)) return;
updatePreferences({ windowDays: days });
setWindowSelection({
days,
window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
});
};
const selectMetric = (nextMetric: UsageMetric) => {
if (nextMetric === "limits") setLimitsNow(Date.now());
const nextPreferences = { metric: nextMetric, windowDays };
setPreferences(nextPreferences);
saveUsagePagePreferences(nextPreferences);
updatePreferences({ metric: nextMetric });
};
const refreshLimits = async (automatic = false, afterPending = false) => {
try {
Expand Down Expand Up @@ -334,7 +343,7 @@ export function UsagePage() {
<h1>Usage</h1>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
<WorkspaceBreadcrumbItem current className="min-w-10">
<WorkspaceBreadcrumbItem className="min-w-10 shrink">
<UsageEnvironmentFilter
environments={environments}
selectedEnvironments={selectedEnvironments}
Expand All @@ -347,6 +356,13 @@ export function UsagePage() {
onOpenModelPrices={() => setPriceDialog({})}
/>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
<WorkspaceBreadcrumbItem current className="min-w-10">
<UsageProviderFilter
hiddenProviders={hiddenProviders}
onChange={(next) => updatePreferences({ hiddenProviders: next })}
/>
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
{!showingLimits ? (
<span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
Expand Down Expand Up @@ -482,6 +498,7 @@ export function UsagePage() {
) : showingLimits ? (
<UsageLimitsSection
selectedEnvironmentIds={selectedEnvironmentIds}
hiddenProviders={hiddenProviders}
now={limitsNow}
cursorPrompt={
cursorAccessEnvironments.length > 0 ? (
Expand Down Expand Up @@ -550,7 +567,8 @@ export function UsagePage() {
</span>
</div>

{[...presentations].some(
{!hiddenProviders.has("codex") &&
[...presentations].some(
([id, presentation]) =>
(selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&
presentation.serverConfig?.providers.some(usesChatGptSharing),
Expand Down Expand Up @@ -1234,6 +1252,66 @@ function UsageEnvironmentFilter({
);
}

/** Provider visibility shared by every tab. Stored as the hidden set. */
function UsageProviderFilter({
hiddenProviders,
onChange,
}: {
readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
readonly onChange: (hiddenProviders: readonly UsageProviderKind[]) => void;
}) {
const visible = PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider));
const label =
visible.length === PROVIDER_ORDER.length
? "All providers"
: visible.length === 0
? "No providers"
: visible.length === 1
? PROVIDER_PRESENTATION[visible[0]!].label
: `${visible.length} providers`;

return (
<Menu>
<MenuTrigger render={<InlineButton />} className="group/usage-provider min-w-0 max-w-full">
<span className="min-w-0 truncate">{label}</span>
<ChevronDownIcon
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/usage-provider:opacity-100 group-focus-visible/usage-provider:opacity-100 group-data-popup-open/usage-provider:opacity-100"
aria-hidden
/>
</MenuTrigger>
<MenuPopup align="start">
<MenuCheckboxItem
checked={hiddenProviders.size === 0}
closeOnClick={false}
onCheckedChange={(checked) => onChange(checked ? [] : PROVIDER_ORDER)}
>
All providers
</MenuCheckboxItem>
<MenuSeparator />
{PROVIDER_ORDER.map((provider) => (
<MenuCheckboxItem
key={provider}
checked={!hiddenProviders.has(provider)}
closeOnClick={false}
onCheckedChange={(checked) =>
onChange(
PROVIDER_ORDER.filter((entry) =>
entry === provider ? !checked : hiddenProviders.has(entry),
),
)
}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderMark provider={provider} className="size-3.5" />
<span className="truncate">{PROVIDER_PRESENTATION[provider].label}</span>
</span>
</MenuCheckboxItem>
))}
</MenuPopup>
</Menu>
);
}

/**
* Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
* breathes with the same `animate-skeleton` pulse as every other loading state.
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/usage/usagePagePreferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe("Usage page preferences", () => {
}
});

it("round-trips hidden providers and reads older saves without them", () => {
saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });
expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);

values.set(key, '{"metric":"tokens","windowDays":30}');
expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 30 });
});

it.each([
"not-json",
'{"metric":"unknown","windowDays":7}',
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/usage/usagePagePreferences.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UsageProviderKind } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
Expand All @@ -6,6 +7,8 @@ const STORAGE_KEY = "t3code:usage-page-preferences:v1";
const UsagePagePreferencesSchema = Schema.Struct({
metric: Schema.Literals(["cost", "tokens", "limits"]),
windowDays: Schema.Literals([1, 7, 30, 90]),
/** Providers filtered out of the page. Stored as hidden so new providers show by default. */
hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),
});
export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;

Expand Down
42 changes: 36 additions & 6 deletions apps/web/src/state/usage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import {
EnvironmentId,
UsageDay,
USAGE_CONTRACT_VERSION,
type UsageProviderKind,
} from "@t3tools/contracts";
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
Expand All @@ -17,7 +22,12 @@ const input = {
timeZone: "UTC",
};

function environment(id: string, cost: number | null, hostId = id): EnvironmentUsageStatus {
function environment(
id: string,
cost: number | null,
hostId = id,
provider: UsageProviderKind = "codex",
): EnvironmentUsageStatus {
return {
environmentId: EnvironmentId.make(id),
label: id,
Expand All @@ -35,7 +45,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
buckets: [
{
day: input.sinceDay,
provider: "codex",
provider,
model: id,
totals: {
uncachedInputTokens: 100,
Expand All @@ -56,7 +66,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
{
fingerprint: {
hostId,
provider: "codex",
provider,
resolvedHomePath: "/sessions",
volumeId: hostId,
},
Expand All @@ -77,8 +87,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
let renderer: ReactTestRenderer | undefined;
let latest: UsageView;

function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {
const usage = useUsage(input, selected);
function Probe({
selected,
hidden,
}: {
selected: ReadonlySet<EnvironmentId> | null;
hidden?: ReadonlySet<UsageProviderKind>;
}) {
const usage = useUsage(input, selected, hidden);
useLayoutEffect(() => {
latest = usage;
}, [usage]);
Expand Down Expand Up @@ -166,3 +182,17 @@ describe("usage environment selection", () => {
expect(latest.isPartial).toBe(false);
});
});

describe("usage provider filter", () => {
it("drops hidden providers from totals and sessions, then restores them", async () => {
testState.environments = [environment("a", 10), environment("c", 5, "c", "claude")];
await act(() => renderer?.update(<Probe selected={null} hidden={new Set(["codex"])} />));
expect(latest.merged.costUsd).toBe(5);
expect(latest.merged.sessions).toBe(1);
expect(latest.merged.providers.map((entry) => entry.provider)).toEqual(["claude"]);

await act(() => renderer?.update(<Probe selected={null} />));
expect(latest.merged.costUsd).toBe(15);
expect(latest.merged.sessions).toBe(2);
});
});
36 changes: 35 additions & 1 deletion apps/web/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type EnvironmentId,
type UsageBucket,
type UsageSummary,
type UsageProviderKind,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
Expand Down Expand Up @@ -128,9 +129,39 @@ export function mergeAnsweredUsage(
return mergeUsage(answered, USAGE_CONTRACT_VERSION);
}

const NO_HIDDEN_PROVIDERS: ReadonlySet<UsageProviderKind> = new Set();

/**
* Drops hidden providers' buckets and sources before merging, so totals,
* shares, and session counts all describe only the visible providers.
*/
function withoutProviders(
environments: readonly EnvironmentUsageStatus[],
hiddenProviders: ReadonlySet<UsageProviderKind>,
): readonly EnvironmentUsageStatus[] {
if (hiddenProviders.size === 0) return environments;
return environments.map((environment) =>
environment.summary === null
? environment
: {
...environment,
summary: {
...environment.summary,
buckets: environment.summary.buckets.filter(
(bucket) => !hiddenProviders.has(bucket.provider),
),
sources: environment.summary.sources.filter(
(source) => !hiddenProviders.has(source.fingerprint.provider),
),
},
},
);
}

export function useUsage(
input: UsageSummaryInput,
selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
hiddenProviders: ReadonlySet<UsageProviderKind> = NO_HIDDEN_PROVIDERS,
): UsageView {
const windowKey = useMemo(
() =>
Expand Down Expand Up @@ -183,7 +214,10 @@ export function useUsage(
[selectedEnvironments, windowKey],
);

const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]);
const merged = useMemo(
() => mergeAnsweredUsage(withoutProviders(selectedEnvironments, hiddenProviders)),
[selectedEnvironments, hiddenProviders],
);

const answeredCount = selectedEnvironments.filter(
(environment) => environment.summary !== null,
Expand Down
Loading