diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index b99ffc86..fcb500c7 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -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",
diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json
index d48debff..3fd2e55e 100644
--- a/packages/i18n/src/locales/ru.json
+++ b/packages/i18n/src/locales/ru.json
@@ -899,8 +899,7 @@
"window_weekly": "Неделя",
"window_5h": "5 часов",
"group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше.",
- "shared_group": "Общая для {{count}} моделей",
- "shared_group_members": "Модели с общей квотой:"
+ "measured_from": "Показатель модели {{model}}"
},
"claude_quota": {
"title": "Квота Claude",
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index 4b6f5b4e..53782c56 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -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 额度",
diff --git a/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
index cdf65ad2..e0e011ae 100644
--- a/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
+++ b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts
@@ -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,
@@ -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();
});
});
diff --git a/pages/auth-files/hooks/quotaBar.tsx b/pages/auth-files/hooks/quotaBar.tsx
index c9d9baf1..0db25fd1 100644
--- a/pages/auth-files/hooks/quotaBar.tsx
+++ b/pages/auth-files/hooks/quotaBar.tsx
@@ -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 = (
-
-
-
-
+
+
+
+
{translatedLabel}
{hint ? (
@@ -61,42 +73,24 @@ export const renderQuotaBarNode = (
data-testid="quota-bar-hint"
aria-label={hint}
>
-
+
) : null}
-
+ {detailText ? (
+
+
+ {detailText}
+
+ ) : null}
+
{percentText}
-
- {compact ? null : (
-
-
- {detailText ?? " "}
-
-
- )}
);
+
// 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.
diff --git a/pages/auth-files/hooks/quotaCardSlots.ts b/pages/auth-files/hooks/quotaCardSlots.ts
index 04a4fb87..6535bb21 100644
--- a/pages/auth-files/hooks/quotaCardSlots.ts
+++ b/pages/auth-files/hooks/quotaCardSlots.ts
@@ -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();
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]);
@@ -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 " · ", or just "" 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 =
@@ -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;
};
/**
@@ -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),
@@ -192,6 +140,7 @@ export const resolveQuotaCardSlots = (
},
);
}
+
if (provider === "xai") {
return items.map((item, index) => ({
id: item.key ?? item.label ?? `xai-${index + 1}`,