From 36d4bf35cc909cf7beba06e1971b3e111942ad2d Mon Sep 17 00:00:00 2001 From: kittors Date: Thu, 20 Aug 2026 12:03:41 +0800 Subject: [PATCH] feat(ai-accounts): show both Antigravity quota windows per group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card showed only the 5-hour window. A group shares one weekly limit and one 5-hour limit, so hiding either leaves the account holder guessing which one is about to run out — the real client shows both, grouped, with weekly first, and now so does this. Rows are composed here rather than taken from the backend: the group name comes from the upstream, the window from WindowSeconds, so "Gemini · 周" is localised without translating anything the upstream owns. The group name loses its trailing "models", which carries nothing next to a window and a percentage. A group with only one window — the fallback model view is entirely 5h — gets no window suffix, since there is nothing to disambiguate against. The upstream describes each group in a full sentence. Printed inline it would push the numbers off the card, so it moves behind a hint icon next to the label, together with a short explanation of how the two limits interact. The description is taken off the item as well as out of the row, so it cannot reappear in the detail line reserved for the reset countdown. Sorting needed no change: resolveAuthFileQuotaRank ranks by the tightest quota the card actually shows, and it reuses resolveQuotaCardSlots, so the weekly bucket became eligible the moment the card began showing it. Its test now pins that an account at 3% weekly outranks its own roomier 5h window, which is the behaviour an operator reading the card expects. renderQuotaBar moves into quotaBar.tsx as useQuotaBarRenderer, for the same reason renderQuotaBarNode already lived there: the presentation hook is over the file-size limit and may only shrink. Both touched files ended up smaller than their baselines, which are updated to match. Co-Authored-By: Claude Opus 5 --- packages/i18n/src/locales/en.json | 5 +- packages/i18n/src/locales/ru.json | 5 +- packages/i18n/src/locales/zh-CN.json | 5 +- .../__tests__/authFilesQuotaSort.test.ts | 11 +- .../quotaCardSlots.antigravity.test.ts | 99 ++++ .../components/AuthFilesFilesTab.tsx | 9 +- pages/auth-files/hooks/quotaBar.tsx | 173 ++++--- pages/auth-files/hooks/quotaCardSlots.ts | 434 +++++++++++------- .../hooks/useAuthFilesFilesPresentation.tsx | 11 +- .../auth-files/hooks/useAuthFilesQuotaSort.ts | Bin 7766 -> 7902 bytes scripts/file-size-baseline.json | 4 +- 11 files changed, 486 insertions(+), 270 deletions(-) create mode 100644 pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 1114119a..a2e01b44 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -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", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 737ce7b3..2c1b94b0 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -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", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index dd7163e3..c5ac4b8d 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -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 额度", diff --git a/pages/auth-files/__tests__/authFilesQuotaSort.test.ts b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts index df754cc4..a5f182f9 100644 --- a/pages/auth-files/__tests__/authFilesQuotaSort.test.ts +++ b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts @@ -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", () => { diff --git a/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts new file mode 100644 index 00000000..ffed0191 --- /dev/null +++ b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts @@ -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"); + }); +}); diff --git a/pages/auth-files/components/AuthFilesFilesTab.tsx b/pages/auth-files/components/AuthFilesFilesTab.tsx index bc892ba2..68c8ab6b 100644 --- a/pages/auth-files/components/AuthFilesFilesTab.tsx +++ b/pages/auth-files/components/AuthFilesFilesTab.tsx @@ -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; @@ -673,10 +674,7 @@ interface AuthFilesFilesTabProps { cycleBudgetByAuthIndex: Record; 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; requestResetCredit: (file: AuthFileItem) => void; resettingCreditFileName: string | null; @@ -703,6 +701,7 @@ interface AuthFilesFilesTabProps { label: string, item: QuotaItem | null, compact?: boolean, + hint?: string, ) => ReactNode; renderQuotaErrorBadge: (errorText: string) => ReactNode; openTagsEditor: (file: AuthFileItem) => void; @@ -2097,7 +2096,7 @@ export function AuthFilesFilesTab({ {slots.length > 0 ? (
{slots.map((slot) => - renderQuotaBar(slot.label, slot.item, denseCards), + renderQuotaBar(slot.label, slot.item, denseCards, slot.hint), )}
) : ( diff --git a/pages/auth-files/hooks/quotaBar.tsx b/pages/auth-files/hooks/quotaBar.tsx index 9b46b7d7..c9d9baf1 100644 --- a/pages/auth-files/hooks/quotaBar.tsx +++ b/pages/auth-files/hooks/quotaBar.tsx @@ -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"; @@ -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 = ( -
-
- - - {translatedLabel} - - - {percentText} - -
-
+
+ - - {compact ? null : ( -
- - {detailText ?? " "} - -
- )} + {translatedLabel} + {hint ? ( + + + + + + ) : null} +
+ + {percentText} +
- ); - // 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
{bar}
; - } - return ( - -
{bar}
-
- ); + + {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. + if (!compact) { + return
{bar}
; + } + return ( + +
{bar}
+
+ ); }; + +/** + * 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], + ); diff --git a/pages/auth-files/hooks/quotaCardSlots.ts b/pages/auth-files/hooks/quotaCardSlots.ts index 146a3d34..45491d28 100644 --- a/pages/auth-files/hooks/quotaCardSlots.ts +++ b/pages/auth-files/hooks/quotaCardSlots.ts @@ -4,32 +4,102 @@ import { translateParameterizedQuotaLabel, translateXaiQuotaLabel, } from "@code-proxy/domain"; -import { - filterAntigravityQuotaItems, - type QuotaItem, -} from "@features/quota-preview/quota-helpers"; +import { filterAntigravityQuotaItems, type QuotaItem } from "@features/quota-preview/quota-helpers"; import { type QuotaProvider } from "@features/quota-preview/quota-fetch"; export type QuotaCardSlot = { id: string; label: string; item: QuotaItem | null; + /** + * Explanatory text for the row, surfaced behind an icon rather than printed. + * The upstream describes each Antigravity group in a full sentence; inline it + * would push the numbers off the card, which is the opposite of what a quota + * row is for. + */ + hint?: string; }; const WEEK_SECONDS = 7 * 24 * 60 * 60; +const FIVE_HOUR_SECONDS = 5 * 60 * 60; /** - * Keep the windows narrower than a week. + * Strip the trailing noun the upstream appends to every group name. * - * Items with no declared window come from the flat model view, which only ever - * reports the 5h window — dropping them would empty the card for accounts whose - * grouped summary is unavailable. + * 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. */ -export const selectAntigravityShortWindowItems = (items: QuotaItem[]): QuotaItem[] => { - const short = items.filter( - (item) => typeof item.windowSeconds !== "number" || item.windowSeconds < WEEK_SECONDS, - ); - return short.length > 0 ? short : items; +const shortenAntigravityGroupName = (name: string): string => { + const trimmed = name.trim(); + const shortened = trimmed.replace(/\s+models?$/i, "").trim(); + return shortened || trimmed; +}; + +/** + * Order the rows the way the upstream client presents them: grouped by model + * family, weekly above the 5-hour window inside each group. + * + * 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. + */ +const orderAntigravityWindows = ( + items: QuotaItem[], +): Array<{ + item: QuotaItem; + showWindow: boolean; +}> => { + const groups = new Map(); + items.forEach((item) => { + const groupKey = String(item.label ?? item.key ?? ""); + const bucket = groups.get(groupKey); + if (bucket) bucket.push(item); + else groups.set(groupKey, [item]); + }); + + 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; + return [...bucket] + .sort((a, b) => windowRank(a) - windowRank(b)) + .map((item) => ({ item, showWindow })); + }); +}; + +/** + * 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 => { + const group = shortenAntigravityGroupName(String(item.label ?? "")); + if (!showWindow) return group; + const windowLabel = + item.windowSeconds === WEEK_SECONDS + ? t("antigravity_quota.window_weekly") + : item.windowSeconds === FIVE_HOUR_SECONDS + ? t("antigravity_quota.window_5h") + : null; + if (!windowLabel) return group; + return group ? `${group} · ${windowLabel}` : windowLabel; }; /** @@ -43,177 +113,185 @@ export const resolveQuotaCardSlots = ( items: QuotaItem[], t: TFunction, ): QuotaCardSlot[] => { - const translateQuotaLabel = (text: string) => { - if (!text) return text; - if (text.startsWith("m_quota.")) return t(text); - const additionalQuota = parseAdditionalQuotaWindowLabel(text); - if (additionalQuota) { - return t(`m_quota.additional_${additionalQuota.window}`, { - name: additionalQuota.name, - }); - } - if (text.startsWith("claude_quota.")) return translateParameterizedQuotaLabel(t, text); - if (text.startsWith("antigravity_quota.")) return t(text); - if (text.startsWith("xai_quota.")) return translateXaiQuotaLabel(t, text); - return text; - }; - - if (provider === "claude") { - return items.map((item) => ({ - id: item.key ?? item.label, - label: translateQuotaLabel(item.label), - item, - })); + const translateQuotaLabel = (text: string) => { + if (!text) return text; + if (text.startsWith("m_quota.")) return t(text); + const additionalQuota = parseAdditionalQuotaWindowLabel(text); + if (additionalQuota) { + return t(`m_quota.additional_${additionalQuota.window}`, { + name: additionalQuota.name, + }); } - if (provider === "antigravity") { - // The card shows the short window only. The upstream reports both a 5h and - // a weekly bucket per family, and stacking all of them here doubles the row - // count for a card that has room for a handful; the weekly figures are on - // the detail panel. - return selectAntigravityShortWindowItems(filterAntigravityQuotaItems(items)).map( - (item, index) => ({ + if (text.startsWith("claude_quota.")) return translateParameterizedQuotaLabel(t, text); + if (text.startsWith("antigravity_quota.")) return t(text); + if (text.startsWith("xai_quota.")) return translateXaiQuotaLabel(t, text); + return text; + }; + + if (provider === "claude") { + return items.map((item) => ({ + id: item.key ?? item.label, + label: translateQuotaLabel(item.label), + item, + })); + } + 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. + const { meta, ...itemWithoutMeta } = item; + const hint = [meta, t("antigravity_quota.group_hint")] + .map((part) => (typeof part === "string" ? part.trim() : "")) + .filter(Boolean) + .join("\n\n"); + return { id: item.key ?? item.label ?? `antigravity-${index + 1}`, - label: translateQuotaLabel(item.label), - item, - }), - ); - } - if (provider === "xai") { - return items.map((item, index) => ({ - id: item.key ?? item.label ?? `xai-${index + 1}`, - label: translateQuotaLabel(item.label), - item, - })); - } + label: buildAntigravityRowLabel(item, showWindow, t), + item: itemWithoutMeta, + ...(hint ? { hint } : {}), + }; + }, + ); + } + if (provider === "xai") { + return items.map((item, index) => ({ + id: item.key ?? item.label ?? `xai-${index + 1}`, + label: translateQuotaLabel(item.label), + item, + })); + } - const supportsStableCodingSlots = provider === "codex" || provider === "kimi"; - if (!supportsStableCodingSlots) { - // Rank data-bearing windows first so placeholder rows (e.g. kiro's - // subscription entry with percent: null) never crowd out real quotas. - const ranked = [ - ...items.filter((item) => typeof item.percent === "number" || Boolean(item.value)), - ...items.filter((item) => typeof item.percent !== "number" && !item.value), - ]; - return ranked.slice(0, 3).map((item) => ({ - id: item.key ?? item.label, - label: translateQuotaLabel(item.label), - item, - })); - } + const supportsStableCodingSlots = provider === "codex" || provider === "kimi"; + if (!supportsStableCodingSlots) { + // Rank data-bearing windows first so placeholder rows (e.g. kiro's + // subscription entry with percent: null) never crowd out real quotas. + const ranked = [ + ...items.filter((item) => typeof item.percent === "number" || Boolean(item.value)), + ...items.filter((item) => typeof item.percent !== "number" && !item.value), + ]; + return ranked.slice(0, 3).map((item) => ({ + id: item.key ?? item.label, + label: translateQuotaLabel(item.label), + item, + })); + } - const normalize = (value: string) => - value - .trim() - .toLowerCase() - .replaceAll(/[^a-z0-9\u4e00-\u9fff]/g, ""); + const normalize = (value: string) => + value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9\u4e00-\u9fff]/g, ""); - const candidates = items - .filter((item) => !parseAdditionalQuotaWindowLabel(String(item.label ?? ""))) - .map((item) => ({ - item, - key: normalize(`${String(item.key ?? "")} ${String(item.label ?? "")}`), - })); - - // One item may only fill one slot. The loose regex fallbacks overlap — a - // `review_week` entry matches the /weekly|week|周/ branch that backs - // `code_week` too — so without claiming, a card missing its code windows - // rendered the review window twice under two different labels. - const claimed = new Set(); - const claim = (item: QuotaItem | null | undefined): QuotaItem | null => { - if (!item || claimed.has(item)) return null; - claimed.add(item); - return item; - }; - const findExact = (label: string) => - items.find((item) => item.label === label && !claimed.has(item)) ?? null; - const findKey = (...keys: string[]) => - items.find((item) => { - if (claimed.has(item)) return false; - const normalizedKey = normalize(String(item.key ?? "")); - return keys.some((key) => normalizedKey === normalize(key)); - }) ?? null; - const find = (re: RegExp) => - candidates.find((candidate) => !claimed.has(candidate.item) && re.test(candidate.key)) - ?.item ?? null; - - // Exact-key matches are resolved before any regex fallback so a precise - // review_* entry cannot be consumed by the code_* fuzzy branch first. - const codeFiveHourExact = findKey("code_5h", "code5h") ?? findExact("m_quota.code_5h"); - const codeWeekExact = - findKey("code_week", "code_weekly", "codeweekly") ?? findExact("m_quota.code_weekly"); - const reviewFiveHourExact = findKey("review_5h", "review5h") ?? findExact("m_quota.review_5h"); - const reviewWeekExact = - findKey("review_week", "review_weekly", "reviewweekly") ?? - findExact("m_quota.review_weekly"); - const codeFiveHour = claim(codeFiveHourExact); - const codeWeek = claim(codeWeekExact); - const reviewFiveHour = claim(reviewFiveHourExact); - const reviewWeek = claim(reviewWeekExact); - const codeFiveHourSlot = - codeFiveHour ?? claim(find(/(mquotacode5h|code5h|5h|5小时|fivehour|5hour)/i)); - const codeWeekSlot = - codeWeek ?? claim(find(/(mquotacodeweekly|codeweekly|weekly|week|周)/i)); - const reviewFiveHourSlot = - reviewFiveHour ?? - claim(find(/(mquotareview5h|review5h|review5hour|reviewfivehour|审查5小时|审查:5小时)/i)); - const reviewWeekSlot = - reviewWeek ?? - claim(find(/(mquotareviewweekly|reviewweekly|reviewweek|review_week|审查周|审查:周)/i)); - - const knownItems = claimed; - - const codingSlots: { id: string; label: string; item: QuotaItem | null }[] = []; - if (codeFiveHourSlot) { - codingSlots.push({ - id: "code_5h", - label: translateQuotaLabel("m_quota.code_5h"), - item: codeFiveHourSlot, - }); - } - if (codeWeekSlot) { - codingSlots.push({ - id: "code_week", - label: translateQuotaLabel("m_quota.code_weekly"), - item: codeWeekSlot, - }); - } - if (provider === "kimi") { - // Unmatched kimi payloads fall back to raw items instead of an empty state. - if (codingSlots.length > 0) return codingSlots; - return items.slice(0, 3).map((item) => ({ - id: item.key ?? item.label, - label: translateQuotaLabel(item.label), - item, - })); - } + const candidates = items + .filter((item) => !parseAdditionalQuotaWindowLabel(String(item.label ?? ""))) + .map((item) => ({ + item, + key: normalize(`${String(item.key ?? "")} ${String(item.label ?? "")}`), + })); - const codexSlots = [...codingSlots]; - if (reviewFiveHourSlot) { - codexSlots.push({ - id: "review_5h", - label: translateQuotaLabel("m_quota.review_5h"), - item: reviewFiveHourSlot, - }); - } - if (reviewWeekSlot) { - codexSlots.push({ - id: "review_week", - label: translateQuotaLabel("m_quota.review_weekly"), - item: reviewWeekSlot, - }); - } + // One item may only fill one slot. The loose regex fallbacks overlap — a + // `review_week` entry matches the /weekly|week|周/ branch that backs + // `code_week` too — so without claiming, a card missing its code windows + // rendered the review window twice under two different labels. + const claimed = new Set(); + const claim = (item: QuotaItem | null | undefined): QuotaItem | null => { + if (!item || claimed.has(item)) return null; + claimed.add(item); + return item; + }; + const findExact = (label: string) => + items.find((item) => item.label === label && !claimed.has(item)) ?? null; + const findKey = (...keys: string[]) => + items.find((item) => { + if (claimed.has(item)) return false; + const normalizedKey = normalize(String(item.key ?? "")); + return keys.some((key) => normalizedKey === normalize(key)); + }) ?? null; + const find = (re: RegExp) => + candidates.find((candidate) => !claimed.has(candidate.item) && re.test(candidate.key))?.item ?? + null; - const extraSlots = items - .filter((item) => !knownItems.has(item)) - .map((item, index) => { - const idKey = item.key ?? (normalize(String(item.label ?? "")) || `quota${index + 1}`); - return { - id: idKey, - label: translateQuotaLabel(item.label), - item, - }; - }); + // Exact-key matches are resolved before any regex fallback so a precise + // review_* entry cannot be consumed by the code_* fuzzy branch first. + const codeFiveHourExact = findKey("code_5h", "code5h") ?? findExact("m_quota.code_5h"); + const codeWeekExact = + findKey("code_week", "code_weekly", "codeweekly") ?? findExact("m_quota.code_weekly"); + const reviewFiveHourExact = findKey("review_5h", "review5h") ?? findExact("m_quota.review_5h"); + const reviewWeekExact = + findKey("review_week", "review_weekly", "reviewweekly") ?? findExact("m_quota.review_weekly"); + const codeFiveHour = claim(codeFiveHourExact); + const codeWeek = claim(codeWeekExact); + const reviewFiveHour = claim(reviewFiveHourExact); + const reviewWeek = claim(reviewWeekExact); + const codeFiveHourSlot = + codeFiveHour ?? claim(find(/(mquotacode5h|code5h|5h|5小时|fivehour|5hour)/i)); + const codeWeekSlot = codeWeek ?? claim(find(/(mquotacodeweekly|codeweekly|weekly|week|周)/i)); + const reviewFiveHourSlot = + reviewFiveHour ?? + claim(find(/(mquotareview5h|review5h|review5hour|reviewfivehour|审查5小时|审查:5小时)/i)); + const reviewWeekSlot = + reviewWeek ?? + claim(find(/(mquotareviewweekly|reviewweekly|reviewweek|review_week|审查周|审查:周)/i)); + + const knownItems = claimed; + + const codingSlots: { id: string; label: string; item: QuotaItem | null }[] = []; + if (codeFiveHourSlot) { + codingSlots.push({ + id: "code_5h", + label: translateQuotaLabel("m_quota.code_5h"), + item: codeFiveHourSlot, + }); + } + if (codeWeekSlot) { + codingSlots.push({ + id: "code_week", + label: translateQuotaLabel("m_quota.code_weekly"), + item: codeWeekSlot, + }); + } + if (provider === "kimi") { + // Unmatched kimi payloads fall back to raw items instead of an empty state. + if (codingSlots.length > 0) return codingSlots; + return items.slice(0, 3).map((item) => ({ + id: item.key ?? item.label, + label: translateQuotaLabel(item.label), + item, + })); + } + + const codexSlots = [...codingSlots]; + if (reviewFiveHourSlot) { + codexSlots.push({ + id: "review_5h", + label: translateQuotaLabel("m_quota.review_5h"), + item: reviewFiveHourSlot, + }); + } + if (reviewWeekSlot) { + codexSlots.push({ + id: "review_week", + label: translateQuotaLabel("m_quota.review_weekly"), + item: reviewWeekSlot, + }); + } + + const extraSlots = items + .filter((item) => !knownItems.has(item)) + .map((item, index) => { + const idKey = item.key ?? (normalize(String(item.label ?? "")) || `quota${index + 1}`); + return { + id: idKey, + label: translateQuotaLabel(item.label), + item, + }; + }); - if (codexSlots.length === 0 && extraSlots.length > 0) return extraSlots; - return [...codexSlots, ...extraSlots]; + if (codexSlots.length === 0 && extraSlots.length > 0) return extraSlots; + return [...codexSlots, ...extraSlots]; }; diff --git a/pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx b/pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx index 8ca1dab1..591e21cd 100644 --- a/pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx +++ b/pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx @@ -49,7 +49,7 @@ import { resolveQuotaProvider, type QuotaProvider } from "@features/quota-previe import { quotaMetaHasMoney, resolveDisplayableQuotaMeta } from "@features/quota-preview/quota-meta"; import { useStickyDisplayPlans } from "./useStickyDisplayPlans"; import { QuotaMetricChips } from "../components/QuotaMetricChips"; -import { renderQuotaBarNode } from "./quotaBar"; +import { useQuotaBarRenderer } from "./quotaBar"; import { filterAntigravityQuotaItems, type QuotaItem, @@ -520,14 +520,7 @@ export function useAuthFilesFilesPresentation({ [resolveQuotaErrorBadgeLabel, t, translateQuotaText], ); - const renderQuotaBar = useCallback( - (label: string, item: QuotaItem | null, compact = false): ReactNode => - renderQuotaBarNode(label, item, compact, { - translateQuotaText, - formatQuotaItemDetailText, - }), - [formatQuotaItemDetailText, translateQuotaText], - ); + const renderQuotaBar = useQuotaBarRenderer(translateQuotaText, formatQuotaItemDetailText); const fileColumns = useMemo[]>(() => { return [ diff --git a/pages/auth-files/hooks/useAuthFilesQuotaSort.ts b/pages/auth-files/hooks/useAuthFilesQuotaSort.ts index 841f438f90ffd42b6b646ecc0406b028564556d4..a6bfb0c8e0e512915b6323ec49bd50bebf91dca7 100644 GIT binary patch delta 278 zcmX|*F-iq75Qedk1rPA*zY|2X5=4B379K;G&CW~0=4Bw6HEg9fuy~%qTJSEG7M{Y= zWEX@f2Il|2`Mz$yw;$W?=;rX=U0y>S!%!NKn1&3hbAmJUNk)#ylJo-iY-q|{vp&~c zUGw4fc=LR|v=hbAM~j_=ASJi>?T+qJnUUGi(wl$qBZ7bN=vy3*LP$bL$ftR4 zpG}pQ*t6x6hFi9=#!(cRiv{kehSEjU3R_B9?@RAp3KFjSilmY3mQ?9 k3%9{r4S2(1FcF=jN)&v`{XeD0#DyIJOBUy diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index d4b0c72c..7a5d44d3 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -17,9 +17,9 @@ "pages/api-keys/ApiKeysPage.tsx": 1167, "pages/auth-files/AuthFilesPage.tsx": 1210, "pages/auth-files/components/AuthFileDetailModal.tsx": 2070, - "pages/auth-files/components/AuthFilesFilesTab.tsx": 2579, + "pages/auth-files/components/AuthFilesFilesTab.tsx": 2578, "pages/auth-files/hooks/useAuthFilesDetailEditors.ts": 1286, - "pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx": 981, + "pages/auth-files/hooks/useAuthFilesFilesPresentation.tsx": 974, "pages/auth-files/hooks/useAuthFilesStatusState.ts": 1298, "pages/end-users/EndUsersPage.tsx": 1126, "pages/identity-fingerprint/IdentityFingerprintPage.tsx": 1880,