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
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,11 @@
"missing_auth_index": "Auth file missing auth_index",
"empty_models": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
"fetch_all": "Fetch All",
"window_weekly": "Weekly",
"window_5h": "5-hour",
"group_hint": "Models in this group share one weekly limit and one 5-hour limit. Quota is consumed in proportion to token cost, so shorter tasks and cheaper models make it last longer.",
"measured_from": "Measured from {{model}}"
},
"claude_quota": {
"title": "Claude Quota",
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,11 @@
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_models": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
"fetch_all": "Получить все",
"window_weekly": "Неделя",
"window_5h": "5 часов",
"group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше.",
"measured_from": "Показатель модели {{model}}"
},
"claude_quota": {
"title": "Квота Claude",
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,11 @@
"missing_auth_index": "认证文件缺少 auth_index",
"empty_models": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
"fetch_all": "获取全部",
"window_weekly": "周",
"window_5h": "5 小时",
"group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。",
"measured_from": "本行额度取自 {{model}}"
},
"claude_quota": {
"title": "Claude 额度",
Expand Down
4 changes: 2 additions & 2 deletions pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5045,7 +5045,7 @@ describe("AuthFilesPage files table", () => {
expect(await screen.findByText("Code: 5h")).toBeInTheDocument();
expect(screen.getByText("Code: Weekly")).toBeInTheDocument();
expect(screen.queryByText("Review: Weekly")).not.toBeInTheDocument();
expect(screen.getByText("0%")).toHaveClass("text-rose-700");
expect(screen.getByText("0%")).toHaveClass("text-rose-900");
});

test("cards view shows codex team subscription quota instead of empty stable placeholders", async () => {
Expand Down Expand Up @@ -5169,7 +5169,7 @@ describe("AuthFilesPage files table", () => {
expect(metrics[3]).toHaveAttribute("data-layout", "wide");

const previewZero = within(row as HTMLElement).getByText("0%");
expect(previewZero).toHaveClass("text-rose-700");
expect(previewZero).toHaveClass("text-rose-900");

// Every chip carries its own countdown inline, so hovering opens nothing.
for (const metric of metrics) {
Expand Down
11 changes: 7 additions & 4 deletions pages/auth-files/__tests__/authFilesQuotaSort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,25 @@ describe("resolveAuthFileQuotaRank", () => {

// Antigravity reports a weekly bucket too, but the card renders only the 5h
// one. Ranking by a number the operator cannot see reads as a broken sort.
test("ignores windows the antigravity card does not show", () => {
test("ranks antigravity by the tightest window the card shows", () => {
const items: QuotaItem[] = [
{
key: "antigravity:gemini_5h",
label: "Gemini Models · 5h",
label: "Gemini Models",
percent: 72,
windowSeconds: 5 * 60 * 60,
},
{
key: "antigravity:gemini_weekly",
label: "Gemini Models · weekly",
label: "Gemini Models",
percent: 3,
windowSeconds: 7 * 24 * 60 * 60,
},
];
expect(resolveAuthFileQuotaRank(file("antigravity"), items)).toBe(72);
// The card renders both windows per group, so the weekly bucket at 3% is
// visible and must decide the rank — ranking by the roomier 5h window would
// put a nearly-exhausted account ahead of a healthy one.
expect(resolveAuthFileQuotaRank(file("antigravity"), items)).toBe(3);
});

test("is unknown when no visible window carries a number", () => {
Expand Down
143 changes: 143 additions & 0 deletions pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, test } from "vitest";
import type { TFunction } from "i18next";
import { resolveQuotaCardSlots } from "../hooks/quotaCardSlots";
import type { QuotaItem } from "@features/quota-preview/quota-types";

const WEEK = 7 * 24 * 60 * 60;
const FIVE_HOUR = 5 * 60 * 60;

// Return the key so assertions can see which phrase was requested.
const t = ((key: string) => key) as unknown as TFunction;

const summaryItems: QuotaItem[] = [
{
key: "antigravity:gemini_5h",
label: "Gemini Models",
percent: 72,
windowSeconds: FIVE_HOUR,
meta: "Models within this group: Gemini Flash, Gemini Pro",
},
{
key: "antigravity:gemini_weekly",
label: "Gemini Models",
percent: 51,
windowSeconds: WEEK,
meta: "Models within this group: Gemini Flash, Gemini Pro",
},
{
key: "antigravity:3p_5h",
label: "Claude and GPT models",
percent: 100,
windowSeconds: FIVE_HOUR,
},
{
key: "antigravity:3p_weekly",
label: "Claude and GPT models",
percent: 90,
windowSeconds: WEEK,
},
];

describe("antigravity quota card slots", () => {
// The upstream client shows every group with both of its windows, weekly
// first. Hiding either one leaves the account holder guessing which limit is
// about to run out.
test("renders both windows per group, weekly first, grouped together", () => {
const slots = resolveQuotaCardSlots("antigravity", summaryItems, t);
expect(slots.map((slot) => slot.id)).toEqual([
"antigravity:gemini_weekly",
"antigravity:gemini_5h",
"antigravity:3p_weekly",
"antigravity:3p_5h",
]);
});

// "Models" carries no information next to a window and a percentage, and the
// window has to be localised rather than echoing the upstream's own wording.
test("shortens the group name and localises the window", () => {
const slots = resolveQuotaCardSlots("antigravity", summaryItems, t);
expect(slots.map((slot) => slot.label)).toEqual([
"Gemini · antigravity_quota.window_weekly",
"Gemini · antigravity_quota.window_5h",
"Claude and GPT · antigravity_quota.window_weekly",
"Claude and GPT · antigravity_quota.window_5h",
]);
});

// A group with one window has nothing to disambiguate against, so the suffix
// would be pure noise. The fallback model view is entirely 5h.
test("omits the window suffix when a group has only one window", () => {
const slots = resolveQuotaCardSlots(
"antigravity",
[
{
key: "antigravity:gemini_pro",
label: "Gemini Pro",
percent: 40,
windowSeconds: FIVE_HOUR,
},
{ key: "antigravity:claude", label: "Claude", percent: 60, windowSeconds: FIVE_HOUR },
],
t,
);
expect(slots.map((slot) => slot.label)).toEqual(["Gemini Pro", "Claude"]);
});

// The upstream's description is a full sentence. Printed inline it would push
// the numbers off the card, so it belongs behind the hint icon — and it must
// not leak into the row's detail line either.
test("moves the group description into the hint and off the item", () => {
const slots = resolveQuotaCardSlots("antigravity", summaryItems, t);
const gemini = slots[0];
expect(gemini.hint).toContain("Models within this group: Gemini Flash, Gemini Pro");
expect(gemini.hint).toContain("antigravity_quota.group_hint");
expect(gemini.item?.meta).toBeUndefined();
});

// A group the upstream describes with nothing still gets the shared
// explanation of how the two limits interact.
test("still explains the limits when the upstream sends no description", () => {
const slots = resolveQuotaCardSlots("antigravity", summaryItems, t);
const thirdParty = slots[2];
expect(thirdParty.hint).toBe("antigravity_quota.group_hint");
});
});

// The fallback view (fetchAvailableModels) groups models by the quota they
// actually share, which the backend sends as a member list. The row has no
// family name to fall back on — and inventing one is exactly what this
// grouping exists to avoid — so it says how many models draw on the bucket.
describe("antigravity fallback view", () => {
// The fallback view sends one row per family, already measured from a single
// representative model. The card shows the family name as-is; meta names the
// model behind the number and belongs behind the icon.
const familyItems: QuotaItem[] = [
{
key: "antigravity:gemini_pro",
label: "Gemini Pro",
percent: 80,
windowSeconds: FIVE_HOUR,
meta: "gemini-3.1-pro-high",
},
{
key: "antigravity:claude",
label: "Claude",
percent: 90,
windowSeconds: FIVE_HOUR,
meta: "claude-sonnet-4-6",
},
];

test("keeps the family name and adds no window suffix", () => {
const slots = resolveQuotaCardSlots("antigravity", familyItems, t);
expect(slots.map((slot) => slot.label)).toEqual(["Gemini Pro", "Claude"]);
});

test("explains which model the row was measured from, off the item", () => {
const slots = resolveQuotaCardSlots("antigravity", familyItems, t);
expect(slots[0].hint).toContain("antigravity_quota.measured_from");
expect(slots[0].hint).toContain("antigravity_quota.group_hint");
// meta must not survive on the item, where it would reach the countdown.
expect(slots[0].item?.meta).toBeUndefined();
});
});
9 changes: 4 additions & 5 deletions pages/auth-files/components/AuthFilesFilesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
type QuotaState,
} from "@features/quota-preview/quota-helpers";
import type { QuotaProvider } from "@features/quota-preview/quota-fetch";
import type { QuotaCardSlot } from "../hooks/quotaCardSlots";
import { AuthFilesToolbarActions } from "./AuthFilesToolbarActions";

const MAX_FILENAME_PART_LENGTH = 72;
Expand Down Expand Up @@ -673,10 +674,7 @@ interface AuthFilesFilesTabProps {
cycleBudgetByAuthIndex: Record<string, AuthFileCycleBudgetStats>;
statusUsageLoading: boolean;
resolveQuotaProvider: (file: AuthFileItem) => QuotaProvider | null;
resolveQuotaCardSlots: (
provider: QuotaProvider,
items: QuotaItem[],
) => { id: string; label: string; item: QuotaItem | null }[];
resolveQuotaCardSlots: (provider: QuotaProvider, items: QuotaItem[]) => QuotaCardSlot[];
refreshQuota: (file: AuthFileItem, provider: QuotaProvider) => Promise<void>;
requestResetCredit: (file: AuthFileItem) => void;
resettingCreditFileName: string | null;
Expand All @@ -703,6 +701,7 @@ interface AuthFilesFilesTabProps {
label: string,
item: QuotaItem | null,
compact?: boolean,
hint?: string,
) => ReactNode;
renderQuotaErrorBadge: (errorText: string) => ReactNode;
openTagsEditor: (file: AuthFileItem) => void;
Expand Down Expand Up @@ -2097,7 +2096,7 @@ export function AuthFilesFilesTab({
{slots.length > 0 ? (
<div className={denseCards ? "space-y-2" : "space-y-3"}>
{slots.map((slot) =>
renderQuotaBar(slot.label, slot.item, denseCards),
renderQuotaBar(slot.label, slot.item, denseCards, slot.hint),
)}
</div>
) : (
Expand Down
54 changes: 44 additions & 10 deletions pages/auth-files/components/QuotaMetricChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@ import { clampPercent, type QuotaItem } from "@features/quota-preview/quota-help

export type QuotaVisualTone = {
normalized: number | null;
/**
* Bar fill: a light tint plus a 2px rule down its right edge. The rule is
* what marks the percentage, which frees the tint to stay light enough to
* read text over.
*/
fillClass: string;
percentClass: string;
fillHex: string;
/** Chip surface (border + background) mirroring the percent tone. */
chipClass: string;
/** Muted label color that stays legible on the chip surface. */
chipLabelClass: string;
/** Bar track: border, plus the background showing left of the fill. */
barTrackClass: string;
/** Bar label, which sits over the fill and has to read against it. */
barLabelClass: string;
/** Bar countdown: quieter than the label, still legible over the fill. */
barMetaClass: string;
};

export const resolveQuotaVisualTone = (percent: number | null | undefined): QuotaVisualTone => {
Expand All @@ -19,45 +30,68 @@ export const resolveQuotaVisualTone = (percent: number | null | undefined): Quot
if (normalized === null) {
return {
normalized,
fillClass: "bg-slate-300/50 dark:bg-white/10",
fillClass:
"border-r-2 border-slate-300 bg-slate-100 dark:border-white/20 dark:bg-white/[0.07]",
percentClass: "text-slate-900 dark:text-white",
fillHex: "#cbd5e1",
chipClass: "border-slate-900/8 bg-slate-50 dark:border-white/10 dark:bg-white/[0.06]",
chipLabelClass: "text-slate-600 dark:text-white/70",
barTrackClass: "border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.03]",
barLabelClass: "text-slate-700 dark:text-white/80",
barMetaClass: "text-slate-500 dark:text-white/50",
};
}

// The bar's fill is the row's own background, so at 100% it covers the entire
// line — and repeated down a grid of cards, a saturated fill becomes a wall of
// colour that is tiring to look at and, being always on, signals nothing.
//
// So the tint stays light and a 2px rule at the fill's right edge carries the
// percentage instead. The edge marks the value more precisely than a block
// boundary does, and the label keeps its contrast because it is no longer
// sitting on a saturated ground.
if (normalized >= 60) {
return {
normalized,
fillClass: "bg-emerald-500",
percentClass: "text-emerald-700 dark:text-emerald-200",
fillClass:
"border-r-2 border-emerald-400 bg-emerald-100 dark:border-emerald-400/70 dark:bg-emerald-500/20",
percentClass: "text-emerald-900 dark:text-emerald-100",
fillHex: "#10b981",
chipClass:
"border-emerald-200/70 bg-emerald-50/70 dark:border-emerald-500/20 dark:bg-emerald-500/[0.08]",
chipLabelClass: "text-emerald-900/70 dark:text-emerald-100/70",
chipLabelClass: "text-emerald-900 dark:text-emerald-100/80",
barTrackClass: "border-emerald-200 bg-white dark:border-emerald-500/20 dark:bg-white/[0.03]",
barLabelClass: "text-emerald-900 dark:text-emerald-50",
barMetaClass: "text-emerald-700 dark:text-emerald-200/70",
};
}

if (normalized >= 20) {
return {
normalized,
fillClass: "bg-amber-500",
percentClass: "text-amber-700 dark:text-amber-200",
fillClass:
"border-r-2 border-amber-400 bg-amber-100 dark:border-amber-400/70 dark:bg-amber-500/20",
percentClass: "text-amber-900 dark:text-amber-100",
fillHex: "#f59e0b",
chipClass:
"border-amber-200/70 bg-amber-50/70 dark:border-amber-500/20 dark:bg-amber-500/[0.08]",
chipLabelClass: "text-amber-900/70 dark:text-amber-100/70",
chipLabelClass: "text-amber-900 dark:text-amber-100/80",
barTrackClass: "border-amber-200 bg-white dark:border-amber-500/20 dark:bg-white/[0.03]",
barLabelClass: "text-amber-900 dark:text-amber-50",
barMetaClass: "text-amber-700 dark:text-amber-200/70",
};
}

return {
normalized,
fillClass: "bg-rose-500",
percentClass: "text-rose-700 dark:text-rose-200",
fillClass: "border-r-2 border-rose-400 bg-rose-100 dark:border-rose-400/70 dark:bg-rose-500/20",
percentClass: "text-rose-900 dark:text-rose-100",
fillHex: "#f43f5e",
chipClass: "border-rose-200/70 bg-rose-50/70 dark:border-rose-500/20 dark:bg-rose-500/[0.08]",
chipLabelClass: "text-rose-900/70 dark:text-rose-100/70",
chipLabelClass: "text-rose-900 dark:text-rose-100/80",
barTrackClass: "border-rose-200 bg-white dark:border-rose-500/20 dark:bg-white/[0.03]",
barLabelClass: "text-rose-900 dark:text-rose-50",
barMetaClass: "text-rose-700 dark:text-rose-200/70",
};
};

Expand Down
Loading
Loading