From 2f4d80e0d0c93820cb712c5071edef68b8bb26ae Mon Sep 17 00:00:00 2001 From: kittors Date: Thu, 20 Aug 2026 23:37:43 +0800 Subject: [PATCH] feat(ai-accounts): show which models share an Antigravity quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card drew five independent bars — Gemini Pro, Gemini Flash, Gemini Image, Claude, GPT-OSS — for what is, on the live account, a single shared quota. Asked what the bars meant, there was no way to tell they were the same limit counted five times. The backend now groups models by the quota they actually draw on, so a row corresponds to one real bucket. It has no family name to show, and inventing one is what this grouping exists to avoid, so the row says how many models share it: "19 个模型共享". The member list goes behind the hint icon under a heading, one model per line, rather than as a bare comma-separated string in the row. Shared buckets group by their own key rather than by label, since they all carry the same label key — they render alike but are distinct quotas and must stay distinct rows. The named-group path is untouched: an account that can read the upstream summary still shows the upstream's own group names with both windows. Co-Authored-By: Claude Opus 5 --- packages/i18n/src/locales/en.json | 4 +- packages/i18n/src/locales/ru.json | 4 +- packages/i18n/src/locales/zh-CN.json | 4 +- .../quotaCardSlots.antigravity.test.ts | 48 +++++++++++++++++++ pages/auth-files/hooks/quotaCardSlots.ts | 38 ++++++++++++++- 5 files changed, 93 insertions(+), 5 deletions(-) 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");