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
3 changes: 1 addition & 2 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1028,8 +1028,7 @@
"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.",
"shared_group": "Shared by {{count}} models",
"shared_group_members": "Models sharing this quota:"
"measured_from": "Measured from {{model}}"
},
"claude_quota": {
"title": "Claude Quota",
Expand Down
3 changes: 1 addition & 2 deletions packages/i18n/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -899,8 +899,7 @@
"window_weekly": "Неделя",
"window_5h": "5 часов",
"group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше.",
"shared_group": "Общая для {{count}} моделей",
"shared_group_members": "Модели с общей квотой:"
"measured_from": "Показатель модели {{model}}"
},
"claude_quota": {
"title": "Квота Claude",
Expand Down
3 changes: 1 addition & 2 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1036,8 +1036,7 @@
"window_weekly": "周",
"window_5h": "5 小时",
"group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。",
"shared_group": "{{count}} 个模型共享",
"shared_group_members": "共享此额度的模型:"
"measured_from": "本行额度取自 {{model}}"
},
"claude_quota": {
"title": "Claude 额度",
Expand Down
64 changes: 30 additions & 34 deletions pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ describe("antigravity quota card slots", () => {
const slots = resolveQuotaCardSlots(
"antigravity",
[
{ key: "antigravity:gemini_pro", label: "Gemini Pro", percent: 40, windowSeconds: FIVE_HOUR },
{
key: "antigravity:gemini_pro",
label: "Gemini Pro",
percent: 40,
windowSeconds: FIVE_HOUR,
},
{ key: "antigravity:claude", label: "Claude", percent: 60, windowSeconds: FIVE_HOUR },
],
t,
Expand Down Expand Up @@ -98,50 +103,41 @@ describe("antigravity quota card slots", () => {
});
});


// 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 shared buckets (fallback view)", () => {
const sharedItems: QuotaItem[] = [
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:group_gemini_2_5_flash",
label: "antigravity_quota.shared_group",
percent: 100,
meta: "gemini-2.5-flash,gemini-2.5-pro",
key: "antigravity:gemini_pro",
label: "Gemini Pro",
percent: 80,
windowSeconds: FIVE_HOUR,
meta: "gemini-3.1-pro-high",
},
{
key: "antigravity:group_claude_sonnet_4_6",
label: "antigravity_quota.shared_group",
percent: 100,
meta: "claude-sonnet-4-6,gemini-3.1-pro-high,gpt-oss-120b-medium",
key: "antigravity:claude",
label: "Claude",
percent: 90,
windowSeconds: FIVE_HOUR,
meta: "claude-sonnet-4-6",
},
];

test("names each row by how many models share the bucket", () => {
const slots = resolveQuotaCardSlots("antigravity", sharedItems, t);
expect(slots.map((slot) => slot.label)).toEqual([
"antigravity_quota.shared_group",
"antigravity_quota.shared_group",
]);
// Distinct buckets must stay distinct rows even though they render alike.
expect(slots.map((slot) => slot.id)).toEqual([
"antigravity:group_gemini_2_5_flash",
"antigravity:group_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"]);
});

// The member list answers "what does this bar cover" and belongs behind the
// icon; a bare comma-separated string in the row would be unreadable.
test("lists the sharing models in the hint, one per line", () => {
const slots = resolveQuotaCardSlots("antigravity", sharedItems, t);
expect(slots[1].hint).toContain("claude-sonnet-4-6");
expect(slots[1].hint).toContain("gpt-oss-120b-medium");
expect(slots[1].hint).toContain("antigravity_quota.shared_group_members");
expect(slots[1].hint).toContain("antigravity_quota.group_hint");
// The raw list must not survive on the item, where it would reach the
// detail line reserved for the reset countdown.
expect(slots[1].item?.meta).toBeUndefined();
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();
});
});
74 changes: 34 additions & 40 deletions pages/auth-files/hooks/quotaBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,32 @@ export const renderQuotaBarNode = (
const detailText = formatQuotaItemDetailText(item);
const tooltipParts = [translatedLabel, percentText];
if (detailText) tooltipParts.push(detailText);
// One line per quota: the fill is the row's background rather than a separate
// track underneath it, so a card fits twice as many windows at the same
// height. Label, countdown and percentage share the line, which is what makes
// the numbers scannable down a column instead of hunting between two rows.
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
/>
<div
className={[
"relative flex w-full items-center overflow-hidden rounded-md",
"border border-slate-200/70 bg-slate-50/60 dark:border-white/5 dark:bg-white/[0.04]",
compact ? "h-[22px] px-1.5" : "h-6 px-2",
].join(" ")}
>
<div
className={["absolute inset-y-0 left-0 opacity-20 dark:opacity-25", tone.fillClass].join(
" ",
)}
style={{ width: `${normalized ?? 0}%` }}
aria-hidden="true"
/>
<div
className={[
"relative z-10 flex w-full items-center gap-1.5 leading-none",
compact ? "text-2xs" : "text-xs",
].join(" ")}
>
<span className="inline-flex min-w-0 flex-1 items-center gap-1 font-medium text-slate-600 dark:text-white/70">
<span className="min-w-0 truncate">{translatedLabel}</span>
{hint ? (
<HoverTooltip content={hint} placement="top" className="shrink-0">
Expand All @@ -61,42 +73,24 @@ export const renderQuotaBarNode = (
data-testid="quota-bar-hint"
aria-label={hint}
>
<Info size={compact ? 11 : 12} aria-hidden />
<Info size={compact ? 10 : 11} aria-hidden />
</span>
</HoverTooltip>
) : null}
</span>
<span
className={[
"shrink-0 font-semibold tabular-nums",
compact ? "text-2xs" : "text-xs",
tone.percentClass,
].join(" ")}
>
{detailText ? (
<span className="inline-flex max-w-[46%] shrink-0 items-center gap-0.5 truncate tabular-nums text-slate-400 dark:text-white/40">
<Clock size={compact ? 9 : 10} className="shrink-0" aria-hidden />
{detailText}
</span>
) : null}
<span className={["shrink-0 font-semibold tabular-nums", tone.percentClass].join(" ")}>
{percentText}
</span>
</div>
<div
className={[
"w-full overflow-hidden rounded-full bg-slate-100 dark:bg-white/10",
compact ? "h-1.5" : "h-2",
].join(" ")}
>
<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.
Expand Down
109 changes: 29 additions & 80 deletions pages/auth-files/hooks/quotaCardSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,45 +24,31 @@ const WEEK_SECONDS = 7 * 24 * 60 * 60;
const FIVE_HOUR_SECONDS = 5 * 60 * 60;

/**
* Strip the trailing noun the upstream appends to every group name.
* Strip the trailing noun the upstream appends to its group names.
*
* It sends "Gemini Models" and "Claude and GPT models"; the card renders these
* beside a window and a percentage, where the word "models" is the one part
* that carries no information. This trims a generic suffix, not a list of known
* groups — a group the upstream adds tomorrow is shortened the same way.
* It sends "Gemini Models" and "Claude and GPT models"; on a row that already
* shows a window and a percentage, the word "models" is the one part carrying
* no information. A generic suffix, not a list of known groups.
*/
const shortenAntigravityGroupName = (name: string): string => {
const trimmed = name.trim();
const shortened = trimmed.replace(/\s+models?$/i, "").trim();
return shortened || trimmed;
return trimmed.replace(/\s+models?$/i, "").trim() || trimmed;
};

/**
* Order the rows the way the upstream client presents them: grouped by model
* family, weekly above the 5-hour window inside each group.
* Order rows the way the upstream client does: grouped, weekly above 5-hour.
*
* Groups keep the order the upstream returned them in rather than being sorted
* here, so the card matches what the account holder sees in the real client.
*
* Each row is tagged with whether its group actually spans more than one
* window. A group with a single window needs no window suffix — the fallback
* model view reports only 5h, and appending "· 5-hour" to every row there is
* noise on a card whose whole job is to show numbers.
* Only the grouped-summary view has two windows per group. The fallback view
* reports one row per model family, and tagging those with a window suffix
* would be noise, so the flag says whether the group has anything to
* disambiguate against.
*/
const orderAntigravityWindows = (
items: QuotaItem[],
): Array<{
item: QuotaItem;
showWindow: boolean;
}> => {
): Array<{ item: QuotaItem; showWindow: boolean }> => {
const groups = new Map<string, QuotaItem[]>();
items.forEach((item) => {
// Shared buckets all carry the same label key, so they group by their own
// key instead — they are distinct quotas that merely render alike.
const groupKey =
item.label === SHARED_GROUP_LABEL
? String(item.key ?? "")
: String(item.label ?? item.key ?? "");
const groupKey = String(item.label ?? item.key ?? "");
const bucket = groups.get(groupKey);
if (bucket) bucket.push(item);
else groups.set(groupKey, [item]);
Expand All @@ -71,49 +57,18 @@ const orderAntigravityWindows = (
const windowRank = (item: QuotaItem) => {
if (item.windowSeconds === WEEK_SECONDS) return 0;
if (item.windowSeconds === FIVE_HOUR_SECONDS) return 1;
// An unrecognised window keeps its place after the two known ones rather
// than being dropped: the upstream may add a window before we name it.
return 2;
};

return [...groups.values()].flatMap((bucket) => {
const distinctWindows = new Set(bucket.map((item) => item.windowSeconds ?? -1));
const showWindow = distinctWindows.size > 1;
const showWindow = new Set(bucket.map((item) => item.windowSeconds ?? -1)).size > 1;
return [...bucket]
.sort((a, b) => windowRank(a) - windowRank(b))
.map((item) => ({ item, showWindow }));
});
};

const SHARED_GROUP_LABEL = "antigravity_quota.shared_group";

/**
* Members of a shared bucket, as the backend sends them: a comma-separated list
* of model ids. The fallback view groups models by the quota they draw on, so
* this list is the answer to "what does this bar cover".
*/
const readSharedGroupMembers = (item: QuotaItem): string[] =>
String(item.meta ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);

/**
* Compose the row label as "<group> · <window>", or just "<group>" when the
* group has nothing to disambiguate against.
*
* The group name comes from the upstream and the window from `windowSeconds`,
* so the label is localised without translating anything the upstream owns.
* The backend used to send "Gemini Models · Weekly Limit Remaining" as one
* string, which was too long for the row and could not be localised at all.
*/
const buildAntigravityRowLabel = (item: QuotaItem, showWindow: boolean, t: TFunction): string => {
// A bucket from the fallback view has no name of its own — it is defined by
// the models that share it, so the row says how many that is. Naming it after
// a model family would be the guess this grouping exists to avoid.
if (item.label === SHARED_GROUP_LABEL) {
return t(SHARED_GROUP_LABEL, { count: readSharedGroupMembers(item).length });
}
const group = shortenAntigravityGroupName(String(item.label ?? ""));
if (!showWindow) return group;
const windowLabel =
Expand All @@ -122,8 +77,7 @@ const buildAntigravityRowLabel = (item: QuotaItem, showWindow: boolean, t: TFunc
: item.windowSeconds === FIVE_HOUR_SECONDS
? t("antigravity_quota.window_5h")
: null;
if (!windowLabel) return group;
return group ? `${group} · ${windowLabel}` : windowLabel;
return windowLabel ? `${group} · ${windowLabel}` : group;
};

/**
Expand Down Expand Up @@ -160,29 +114,23 @@ export const resolveQuotaCardSlots = (
}));
}
if (provider === "antigravity") {
// Show both windows per group, the way the upstream client does: a group
// shares one weekly and one 5-hour limit, and hiding either leaves the
// account holder guessing which one is about to run out.
return orderAntigravityWindows(filterAntigravityQuotaItems(items)).map(
({ item, showWindow }, index) => {
// meta holds the upstream's description of the group. Moving it onto the
// slot keeps it out of the row's detail line, which is reserved for the
// reset countdown, and hands it to the hint icon instead.
// meta names the model this row was measured from (fallback view) or
// describes the group (summary view). Either way it explains the row
// rather than qualifying the number, so it belongs behind the icon and
// must not reach the countdown slot.
const { meta, ...itemWithoutMeta } = item;
// A shared bucket's meta is the member list; spell it out under a
// heading rather than dropping a bare comma-separated string on the
// reader. Named groups keep the upstream's own description.
const members = readSharedGroupMembers(item);
const description =
item.label === SHARED_GROUP_LABEL
? members.length > 0
? `${t("antigravity_quota.shared_group_members")}\n${members.join("\n")}`
: ""
: (meta ?? "");
const hint = [description, t("antigravity_quota.group_hint")]
.map((part) => (typeof part === "string" ? part.trim() : ""))
.filter(Boolean)
.join("\n\n");
const description = typeof meta === "string" ? meta.trim() : "";
// A model id never contains a space; the grouped summary's description
// is a sentence. So the shape tells them apart: one names the model the
// row was measured from, the other is already an explanation.
const explanation = !description
? ""
: description.includes(" ")
? description
: t("antigravity_quota.measured_from", { model: description });
const hint = [explanation, t("antigravity_quota.group_hint")].filter(Boolean).join("\n\n");
return {
id: item.key ?? item.label ?? `antigravity-${index + 1}`,
label: buildAntigravityRowLabel(item, showWindow, t),
Expand All @@ -192,6 +140,7 @@ export const resolveQuotaCardSlots = (
},
);
}

if (provider === "xai") {
return items.map((item, index) => ({
id: item.key ?? item.label ?? `xai-${index + 1}`,
Expand Down
Loading