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 841f438f..a6bfb0c8 100644 Binary files a/pages/auth-files/hooks/useAuthFilesQuotaSort.ts and b/pages/auth-files/hooks/useAuthFilesQuotaSort.ts differ 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,