diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 1114119a..fcb500c7 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -1024,7 +1024,11 @@ "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.", + "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 737ce7b3..3fd2e55e 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -895,7 +895,11 @@ "missing_auth_index": "В файле авторизации отсутствует auth_index", "empty_models": "Данные по квоте отсутствуют", "refresh_button": "Обновить квоту", - "fetch_all": "Получить все" + "fetch_all": "Получить все", + "window_weekly": "Неделя", + "window_5h": "5 часов", + "group_hint": "Модели этой группы делят один недельный и один пятичасовой лимит. Квота расходуется пропорционально стоимости токенов, поэтому короткие задачи и более экономичные модели растягивают её дольше.", + "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 dd7163e3..53782c56 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -1032,7 +1032,11 @@ "missing_auth_index": "认证文件缺少 auth_index", "empty_models": "暂无额度数据", "refresh_button": "刷新额度", - "fetch_all": "获取全部" + "fetch_all": "获取全部", + "window_weekly": "周", + "window_5h": "5 小时", + "group_hint": "本组共享一个周限额和一个 5 小时限额。额度按 token 成本比例扣减,因此任务越短、模型越经济,额度越耐用。", + "measured_from": "本行额度取自 {{model}}" }, "claude_quota": { "title": "Claude 额度", diff --git a/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx b/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx index 13685863..45a09511 100644 --- a/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx +++ b/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx @@ -5045,7 +5045,7 @@ describe("AuthFilesPage files table", () => { expect(await screen.findByText("Code: 5h")).toBeInTheDocument(); expect(screen.getByText("Code: Weekly")).toBeInTheDocument(); expect(screen.queryByText("Review: Weekly")).not.toBeInTheDocument(); - expect(screen.getByText("0%")).toHaveClass("text-rose-700"); + expect(screen.getByText("0%")).toHaveClass("text-rose-900"); }); test("cards view shows codex team subscription quota instead of empty stable placeholders", async () => { @@ -5169,7 +5169,7 @@ describe("AuthFilesPage files table", () => { expect(metrics[3]).toHaveAttribute("data-layout", "wide"); const previewZero = within(row as HTMLElement).getByText("0%"); - expect(previewZero).toHaveClass("text-rose-700"); + expect(previewZero).toHaveClass("text-rose-900"); // Every chip carries its own countdown inline, so hovering opens nothing. for (const metric of metrics) { 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..e0e011ae --- /dev/null +++ b/pages/auth-files/__tests__/quotaCardSlots.antigravity.test.ts @@ -0,0 +1,143 @@ +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"); + }); +}); + +// 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 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:gemini_pro", + label: "Gemini Pro", + percent: 80, + windowSeconds: FIVE_HOUR, + meta: "gemini-3.1-pro-high", + }, + { + key: "antigravity:claude", + label: "Claude", + percent: 90, + windowSeconds: FIVE_HOUR, + meta: "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"]); + }); + + 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/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/components/QuotaMetricChips.tsx b/pages/auth-files/components/QuotaMetricChips.tsx index d8c08012..29ef4216 100644 --- a/pages/auth-files/components/QuotaMetricChips.tsx +++ b/pages/auth-files/components/QuotaMetricChips.tsx @@ -4,6 +4,11 @@ import { clampPercent, type QuotaItem } from "@features/quota-preview/quota-help export type QuotaVisualTone = { normalized: number | null; + /** + * Bar fill: a light tint plus a 2px rule down its right edge. The rule is + * what marks the percentage, which frees the tint to stay light enough to + * read text over. + */ fillClass: string; percentClass: string; fillHex: string; @@ -11,6 +16,12 @@ export type QuotaVisualTone = { chipClass: string; /** Muted label color that stays legible on the chip surface. */ chipLabelClass: string; + /** Bar track: border, plus the background showing left of the fill. */ + barTrackClass: string; + /** Bar label, which sits over the fill and has to read against it. */ + barLabelClass: string; + /** Bar countdown: quieter than the label, still legible over the fill. */ + barMetaClass: string; }; export const resolveQuotaVisualTone = (percent: number | null | undefined): QuotaVisualTone => { @@ -19,45 +30,68 @@ export const resolveQuotaVisualTone = (percent: number | null | undefined): Quot if (normalized === null) { return { normalized, - fillClass: "bg-slate-300/50 dark:bg-white/10", + fillClass: + "border-r-2 border-slate-300 bg-slate-100 dark:border-white/20 dark:bg-white/[0.07]", percentClass: "text-slate-900 dark:text-white", fillHex: "#cbd5e1", chipClass: "border-slate-900/8 bg-slate-50 dark:border-white/10 dark:bg-white/[0.06]", chipLabelClass: "text-slate-600 dark:text-white/70", + barTrackClass: "border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.03]", + barLabelClass: "text-slate-700 dark:text-white/80", + barMetaClass: "text-slate-500 dark:text-white/50", }; } + // The bar's fill is the row's own background, so at 100% it covers the entire + // line — and repeated down a grid of cards, a saturated fill becomes a wall of + // colour that is tiring to look at and, being always on, signals nothing. + // + // So the tint stays light and a 2px rule at the fill's right edge carries the + // percentage instead. The edge marks the value more precisely than a block + // boundary does, and the label keeps its contrast because it is no longer + // sitting on a saturated ground. if (normalized >= 60) { return { normalized, - fillClass: "bg-emerald-500", - percentClass: "text-emerald-700 dark:text-emerald-200", + fillClass: + "border-r-2 border-emerald-400 bg-emerald-100 dark:border-emerald-400/70 dark:bg-emerald-500/20", + percentClass: "text-emerald-900 dark:text-emerald-100", fillHex: "#10b981", chipClass: "border-emerald-200/70 bg-emerald-50/70 dark:border-emerald-500/20 dark:bg-emerald-500/[0.08]", - chipLabelClass: "text-emerald-900/70 dark:text-emerald-100/70", + chipLabelClass: "text-emerald-900 dark:text-emerald-100/80", + barTrackClass: "border-emerald-200 bg-white dark:border-emerald-500/20 dark:bg-white/[0.03]", + barLabelClass: "text-emerald-900 dark:text-emerald-50", + barMetaClass: "text-emerald-700 dark:text-emerald-200/70", }; } if (normalized >= 20) { return { normalized, - fillClass: "bg-amber-500", - percentClass: "text-amber-700 dark:text-amber-200", + fillClass: + "border-r-2 border-amber-400 bg-amber-100 dark:border-amber-400/70 dark:bg-amber-500/20", + percentClass: "text-amber-900 dark:text-amber-100", fillHex: "#f59e0b", chipClass: "border-amber-200/70 bg-amber-50/70 dark:border-amber-500/20 dark:bg-amber-500/[0.08]", - chipLabelClass: "text-amber-900/70 dark:text-amber-100/70", + chipLabelClass: "text-amber-900 dark:text-amber-100/80", + barTrackClass: "border-amber-200 bg-white dark:border-amber-500/20 dark:bg-white/[0.03]", + barLabelClass: "text-amber-900 dark:text-amber-50", + barMetaClass: "text-amber-700 dark:text-amber-200/70", }; } return { normalized, - fillClass: "bg-rose-500", - percentClass: "text-rose-700 dark:text-rose-200", + fillClass: "border-r-2 border-rose-400 bg-rose-100 dark:border-rose-400/70 dark:bg-rose-500/20", + percentClass: "text-rose-900 dark:text-rose-100", fillHex: "#f43f5e", chipClass: "border-rose-200/70 bg-rose-50/70 dark:border-rose-500/20 dark:bg-rose-500/[0.08]", - chipLabelClass: "text-rose-900/70 dark:text-rose-100/70", + chipLabelClass: "text-rose-900 dark:text-rose-100/80", + barTrackClass: "border-rose-200 bg-white dark:border-rose-500/20 dark:bg-white/[0.03]", + barLabelClass: "text-rose-900 dark:text-rose-50", + barMetaClass: "text-rose-700 dark:text-rose-200/70", }; }; diff --git a/pages/auth-files/hooks/quotaBar.tsx b/pages/auth-files/hooks/quotaBar.tsx index 9b46b7d7..50cf6c26 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,122 @@ 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} - + 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); + // 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 = ( +
+ {/* No opacity wrapper: the tone ships a tint already light enough to read + text over, and dimming it further was what made the fill edge — the + thing that actually encodes the percentage — impossible to locate. */} + + ); + + // 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..6535bb21 100644 --- a/pages/auth-files/hooks/quotaCardSlots.ts +++ b/pages/auth-files/hooks/quotaCardSlots.ts @@ -4,32 +4,80 @@ 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; + +/** + * Strip the trailing noun the upstream appends to its group names. + * + * 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(); + return trimmed.replace(/\s+models?$/i, "").trim() || trimmed; +}; /** - * Keep the windows narrower than a week. + * Order rows the way the upstream client does: grouped, weekly above 5-hour. * - * 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. + * 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. */ -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 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; + return 2; + }; + + return [...groups.values()].flatMap((bucket) => { + 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 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; + return windowLabel ? `${group} · ${windowLabel}` : group; }; /** @@ -43,177 +91,190 @@ 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") { + return orderAntigravityWindows(filterAntigravityQuotaItems(items)).map( + ({ item, showWindow }, index) => { + // 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; + 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: 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 } : {}), + }; + }, + ); + } - 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, - })); - } + if (provider === "xai") { + return items.map((item, index) => ({ + id: item.key ?? item.label ?? `xai-${index + 1}`, + label: translateQuotaLabel(item.label), + item, + })); + } - const normalize = (value: string) => - value - .trim() - .toLowerCase() - .replaceAll(/[^a-z0-9\u4e00-\u9fff]/g, ""); + 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 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 normalize = (value: string) => + value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9\u4e00-\u9fff]/g, ""); - 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 candidates = items + .filter((item) => !parseAdditionalQuotaWindowLabel(String(item.label ?? ""))) + .map((item) => ({ + item, + key: normalize(`${String(item.key ?? "")} ${String(item.label ?? "")}`), + })); - 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, - }; - }); + // 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 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,