diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a2e01b44..b99ffc86 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -1027,7 +1027,9 @@ "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." + "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:" }, "claude_quota": { "title": "Claude Quota", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 2c1b94b0..d48debff 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -898,7 +898,9 @@ "fetch_all": "Получить все", "window_weekly": "Неделя", "window_5h": "5 часов", - "group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше." + "group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше.", + "shared_group": "Общая для {{count}} моделей", + "shared_group_members": "Модели с общей квотой:" }, "claude_quota": { "title": "Квота Claude", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index c5ac4b8d..4b6f5b4e 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -1035,7 +1035,9 @@ "fetch_all": "获取全部", "window_weekly": "周", "window_5h": "5 小时", - "group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。" + "group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。", + "shared_group": "{{count}} 个模型共享", + "shared_group_members": "共享此额度的模型:" }, "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 ffed0191..cdf65ad2 100644 --- a/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts +++ b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts @@ -97,3 +97,51 @@ describe("antigravity quota card slots", () => { 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 shared buckets (fallback view)", () => { + const sharedItems: 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: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", + }, + ]; + + 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", + ]); + }); + + // 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(); + }); +}); diff --git a/pages/auth-files/hooks/quotaCardSlots.ts b/pages/auth-files/hooks/quotaCardSlots.ts index 45491d28..04a4fb87 100644 --- a/pages/auth-files/hooks/quotaCardSlots.ts +++ b/pages/auth-files/hooks/quotaCardSlots.ts @@ -57,7 +57,12 @@ const orderAntigravityWindows = ( }> => { const groups = new Map(); items.forEach((item) => { - const groupKey = String(item.label ?? item.key ?? ""); + // 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 bucket = groups.get(groupKey); if (bucket) bucket.push(item); else groups.set(groupKey, [item]); @@ -80,6 +85,19 @@ const orderAntigravityWindows = ( }); }; +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. @@ -90,6 +108,12 @@ const orderAntigravityWindows = ( * 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 = @@ -145,7 +169,17 @@ export const resolveQuotaCardSlots = ( // 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. const { meta, ...itemWithoutMeta } = item; - const hint = [meta, t("antigravity_quota.group_hint")] + // 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");