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
5 changes: 4 additions & 1 deletion packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,10 @@
"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."
},
"claude_quota": {
"title": "Claude Quota",
Expand Down
5 changes: 4 additions & 1 deletion packages/i18n/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,10 @@
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_models": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
"fetch_all": "Получить все",
"window_weekly": "Неделя",
"window_5h": "5 часов",
"group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше."
},
"claude_quota": {
"title": "Квота Claude",
Expand Down
5 changes: 4 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,10 @@
"missing_auth_index": "认证文件缺少 auth_index",
"empty_models": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
"fetch_all": "获取全部",
"window_weekly": "周",
"window_5h": "5 小时",
"group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。"
},
"claude_quota": {
"title": "Claude 额度",
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
99 changes: 99 additions & 0 deletions pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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");
});
});
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
173 changes: 104 additions & 69 deletions pages/auth-files/hooks/quotaBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Clock } from "lucide-react";
import { useCallback, type ReactNode } from "react";
import { Clock, Info } from "lucide-react";
import { HoverTooltip } from "@code-proxy/ui";
import type { QuotaItem } from "@features/quota-preview/quota-helpers";
import { resolveQuotaVisualTone } from "../components/QuotaMetricChips";
Expand All @@ -26,79 +26,114 @@ export const renderQuotaBarNode = (
item: QuotaItem | null,
compact: boolean,
deps: QuotaBarDeps,
hint?: string,
): ReactNode => {
const { translateQuotaText, formatQuotaItemDetailText } = deps;
const tone = resolveQuotaVisualTone(item?.percent);
const normalized = tone.normalized;
const translatedLabel = translateQuotaText(label);
const percentText =
(item?.value ? translateQuotaText(item.value) : undefined) ??
(normalized === null ? "--" : `${Math.round(normalized)}%`);
// Keep a fixed-height meta row so bars stay evenly spaced; hide "--" when empty.
const detailText = formatQuotaItemDetailText(item);
const tooltipParts = [translatedLabel, percentText];
if (detailText) tooltipParts.push(detailText);
const bar = (
<div className={compact ? "space-y-1" : "space-y-1.5"}>
<div className="flex items-center justify-between gap-1.5">
<span
className={[
"inline-flex min-w-0 items-center gap-1 font-medium text-slate-600 dark:text-white/70",
compact ? "text-2xs" : "gap-1.5 text-xs",
].join(" ")}
>
<Clock
size={compact ? 11 : 12}
className="shrink-0 text-slate-400 dark:text-white/40"
aria-hidden
/>
<span className="min-w-0 truncate">{translatedLabel}</span>
</span>
<span
className={[
"shrink-0 font-semibold tabular-nums",
compact ? "text-2xs" : "text-xs",
tone.percentClass,
].join(" ")}
>
{percentText}
</span>
</div>
<div
const tone = resolveQuotaVisualTone(item?.percent);
const normalized = tone.normalized;
const translatedLabel = translateQuotaText(label);
const percentText =
(item?.value ? translateQuotaText(item.value) : undefined) ??
(normalized === null ? "--" : `${Math.round(normalized)}%`);
// Keep a fixed-height meta row so bars stay evenly spaced; hide "--" when empty.
const detailText = formatQuotaItemDetailText(item);
const tooltipParts = [translatedLabel, percentText];
if (detailText) tooltipParts.push(detailText);
const bar = (
<div className={compact ? "space-y-1" : "space-y-1.5"}>
<div className="flex items-center justify-between gap-1.5">
<span
className={[
"w-full overflow-hidden rounded-full bg-slate-100 dark:bg-white/10",
compact ? "h-1.5" : "h-2",
"inline-flex min-w-0 items-center gap-1 font-medium text-slate-600 dark:text-white/70",
compact ? "text-2xs" : "gap-1.5 text-xs",
].join(" ")}
>
<div
className={["h-full rounded-full", tone.fillClass].join(" ")}
style={{ width: `${normalized ?? 0}%` }}
aria-hidden="true"
<Clock
size={compact ? 11 : 12}
className="shrink-0 text-slate-400 dark:text-white/40"
aria-hidden
/>
</div>
{compact ? null : (
<div className="flex min-h-[14px] items-center justify-end gap-2 text-2xs">
<span className="shrink-0 truncate tabular-nums text-slate-400 dark:text-white/40">
{detailText ?? " "}
</span>
</div>
)}
<span className="min-w-0 truncate">{translatedLabel}</span>
{hint ? (
<HoverTooltip content={hint} placement="top" className="shrink-0">
<span
className="inline-flex shrink-0 cursor-help text-slate-400 transition-colors hover:text-slate-600 dark:text-white/40 dark:hover:text-white/70"
data-testid="quota-bar-hint"
aria-label={hint}
>
<Info size={compact ? 11 : 12} aria-hidden />
</span>
</HoverTooltip>
) : null}
</span>
<span
className={[
"shrink-0 font-semibold tabular-nums",
compact ? "text-2xs" : "text-xs",
tone.percentClass,
].join(" ")}
>
{percentText}
</span>
</div>
);
// ponytail: compact drops reset line; full detail stays in tooltip.
// Keyed by quota key, not label: two windows can translate to the same label,
// and a duplicate React key made rows reuse each other's DOM.
if (!compact) {
return <div key={item?.key ?? label}>{bar}</div>;
}
return (
<HoverTooltip
key={item?.key ?? label}
content={tooltipParts.join(" · ")}
placement="top"
className="w-full max-w-full"
<div
className={[
"w-full overflow-hidden rounded-full bg-slate-100 dark:bg-white/10",
compact ? "h-1.5" : "h-2",
].join(" ")}
>
<div className="w-full min-w-0">{bar}</div>
</HoverTooltip>
);
<div
className={["h-full rounded-full", tone.fillClass].join(" ")}
style={{ width: `${normalized ?? 0}%` }}
aria-hidden="true"
/>
</div>
{compact ? null : (
<div className="flex min-h-[14px] items-center justify-end gap-2 text-2xs">
<span className="shrink-0 truncate tabular-nums text-slate-400 dark:text-white/40">
{detailText ?? " "}
</span>
</div>
)}
</div>
);
// ponytail: compact drops reset line; full detail stays in tooltip.
// Keyed by quota key, not label: two windows can translate to the same label,
// and a duplicate React key made rows reuse each other's DOM.
if (!compact) {
return <div key={item?.key ?? label}>{bar}</div>;
}
return (
<HoverTooltip
key={item?.key ?? label}
content={tooltipParts.join(" · ")}
placement="top"
className="w-full max-w-full"
>
<div className="w-full min-w-0">{bar}</div>
</HoverTooltip>
);
};

/**
* Bind the renderer to its dependencies once.
*
* Lives here rather than in useAuthFilesFilesPresentation for the same reason
* renderQuotaBarNode does: that hook is over the file-size limit and may only
* shrink, so the wiring belongs next to the thing it wires.
*/
export const useQuotaBarRenderer = (
translateQuotaText: QuotaBarDeps["translateQuotaText"],
formatQuotaItemDetailText: QuotaBarDeps["formatQuotaItemDetailText"],
) =>
useCallback(
(label: string, item: QuotaItem | null, compact = false, hint?: string): ReactNode =>
renderQuotaBarNode(
label,
item,
compact,
{ translateQuotaText, formatQuotaItemDetailText },
hint,
),
[formatQuotaItemDetailText, translateQuotaText],
);
Loading
Loading