feat(agy): open all four quota windows from the card's quota column

The card keeps one line per group (Gemini, Claude/GPT) to fit, so the
5-hour and weekly windows could not both be seen anywhere on it. On
Antigravity cards the quota column is now one dropdown, like the resets
dropdown on Codex official cards, listing every window with its own
reset time. Refreshing moves to the ↻ button that appears on hover.
Other cards keep click-to-refresh.
This commit is contained in:
Jason committed 2026-10-21 17:04:27 +08:00
1 parent 997e78b565
commit 276eff8dce
8 files changed
+179 -6

No files matched your search

@@ -11,7 +11,9 @@ import {
creditsLine,
failedLines,
resetCreditsLine,
resetText,
tierLine,
type QuotaBreakdown,
type QuotaDisplay,
type QuotaLine,
} from "@/components/quota/quotaRules";
@@ -93,6 +95,34 @@ function collapseInlineGroups(tiers: QuotaTier[]): QuotaTier[] {
);
}
/**
* 卡片按组合并过档位时(Antigravity),整列做成一个下拉逐档列出:
* 「Gemini 5 小时 4h59m后重置 剩余 100%」。没合并时不需要(null)
*/
export function tierBreakdown(
t: TFunction,
tiers: QuotaTier[],
display: QuotaDisplay = "left",
): QuotaBreakdown | null {
const known = tiers.filter((tier) => tier.name in TIER_I18N_KEYS);
if (collapseInlineGroups(known).length === known.length) return null;
return {
title: t("quota.windows.title"),
openLabel: t("quota.windows.showAll"),
items: known.map((tier) => {
const label = tierLabel(t, tier.name);
const line = tierLine(t, tier, label, { display });
return {
key: tier.name,
label,
hint: resetText(t, line) ?? undefined,
value: line.value ?? line.text,
tone: line.tone,
};
}),
};
}
export function tierLabel(t: TFunction, name: string): string {
return TIER_I18N_KEYS[name] ? t(TIER_I18N_KEYS[name]) : name;
}
@@ -254,6 +284,7 @@ export const SubscriptionQuotaView: React.FC<SubscriptionQuotaViewProps> = ({
queriedAt={quota.queriedAt}
loading={loading}
onRefresh={refetch}
breakdown={tierBreakdown(t, quota.tiers || [], display) ?? undefined}
/>
);
}
+7 -4
View File
@@ -16,6 +16,7 @@ import type {
/**
* 一行写不下的额度(存下了好几次重置):整行(卡片上是那一段)是按钮,点开逐条列出明细
* (每一次的到期日,同一天到期的并成一条)。授权中心、卡片额度列和展开的额度条共用。
* 不给 `line` 时标题右边不写值(卡片整列做成一个下拉时,列出的是好几档,没有一个总值)。
*
* `className` 给按钮,用调用方原来那一行的排版;左右各外扩 4px 留出悬停底色,文字不挪位。
*/
@@ -26,7 +27,7 @@ export function QuotaBreakdownRow({
align = "start",
children,
}: {
line: QuotaLine;
line?: QuotaLine;
breakdown: QuotaBreakdown;
className?: string;
/** 弹窗和按钮哪边对齐:卡片额度列靠右,用 end */
@@ -57,9 +58,11 @@ export function QuotaBreakdownRow({
>
<div className="mb-2 flex items-baseline justify-between gap-3 text-caption">
<span className="font-medium text-fg-1">{breakdown.title}</span>
<span className="whitespace-nowrap tabular-nums text-fg-2">
{line.value ?? line.text}
</span>
{line && (
<span className="whitespace-nowrap tabular-nums text-fg-2">
{line.value ?? line.text}
</span>
)}
</div>
<BreakdownItems items={breakdown.items} />
{breakdown.footer && (
+96 -1
View File
@@ -11,6 +11,7 @@ import {
formatRelativeTime,
lineHint,
resetText,
type QuotaBreakdown,
type QuotaLine,
type QuotaTone,
} from "./quotaRules";
@@ -197,12 +198,15 @@ interface QuotaLinesProps {
loading?: boolean;
/** 返回 refetch() 的 Promise 时,点击后的 ✓ / ✗ 也认请求本身的成败 */
onRefresh?: () => unknown;
/** 卡片上放不下的各档:整列做成一个下拉(Antigravity 四档,见 tierBreakdown) */
breakdown?: QuotaBreakdown;
}
/**
* 卡片右侧的额度列(v7):最多两行、右对齐、平时灰色;档数多时第一行写窗口最短的那档,
* 其余并成一行、每段各自上色(cardRows)。点一下重查,悬停说明每档、更新时间和重置时间。
* 能重查时,悬停 / 键盘聚焦在第一行左边露出 ↻,点了之后它转到查完,再按结果换成 ✓ / ✗ 停一秒。
* 给了 `breakdown`(卡片只写得下其中几档)时整列是一个下拉,点开逐档列出;重查改点 ↻。
*/
export function QuotaLines({
lines,
@@ -210,6 +214,7 @@ export function QuotaLines({
queriedAt,
loading = false,
onRefresh,
breakdown,
}: QuotaLinesProps) {
const { t } = useTranslation();
const now = useNow(Boolean(queriedAt) || lines.some((line) => line.resetsAt));
@@ -266,8 +271,9 @@ export function QuotaLines({
const className = cn(
"flex shrink-0 flex-col items-end text-caption leading-[18px] tabular-nums whitespace-nowrap",
// 文字仍是 136 宽,倒计时那格(52 + 间距)另加
// 文字仍是 136 宽,倒计时那格(52 + 间距)另加;整列下拉的 ⌄ 那格(14)也另加
showReset ? "w-[194px]" : "w-[136px]",
breakdown && (showReset ? "w-[208px]" : "w-[150px]"),
loading && !spinning && "opacity-60",
);
@@ -330,6 +336,20 @@ export function QuotaLines({
/>
);
if (breakdown) {
return (
<DropdownQuotaColumn
rows={rows}
breakdown={breakdown}
className={className}
title={title}
icon={icon}
resetSlot={resetSlot}
refreshProps={refreshProps}
/>
);
}
if (split) {
return (
<SplitQuotaColumn
@@ -374,6 +394,81 @@ interface RefreshProps {
onBlur: () => void;
}
/**
* 整列一个下拉:点文字打开各档明细,⌄ 跟在第一行文字后面(其余行留同宽的空位,数字仍竖着对齐)。
* 下拉里不能再套按钮,↻ 单独做成下拉左边的小按钮(不占宽度),悬停整列时露出来。
*/
function DropdownQuotaColumn({
rows,
breakdown,
className,
title,
icon,
resetSlot,
refreshProps,
}: {
rows: QuotaLine[][];
breakdown: QuotaBreakdown;
className: string;
title: string;
icon: ReactNode;
resetSlot: (row: QuotaLine[]) => ReactNode;
refreshProps: RefreshProps;
}) {
const { t } = useTranslation();
return (
<div
className={cn(className, "flex-row items-start justify-end")}
onMouseEnter={refreshProps.onMouseEnter}
onMouseLeave={refreshProps.onMouseLeave}
>
<span className="relative flex min-w-0">
{/* 绝对定位贴在下拉左边、不占宽度:↻ 出现 / 消失时文字不被挤动 */}
<button
type="button"
title={title}
aria-label={t("quota.clickToRefresh")}
className="group absolute end-full top-0 me-1 flex h-[18px] items-center rounded-control focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
{...refreshProps}
// 悬停交给整列:移出这个小按钮、还在列上时 ↻ 不能消失
onMouseEnter={undefined}
onMouseLeave={undefined}
>
{icon}
</button>
<QuotaBreakdownRow
breakdown={breakdown}
align="end"
className="flex min-w-0 flex-col items-end text-fg-2"
>
{rows.map((row, rowIndex) => (
<span
key={row.map((line) => line.key).join("+")}
className="flex max-w-full items-center justify-end"
>
<span className="min-w-0 truncate">
{row.map((line, index) => (
<span key={line.key}>
{index > 0 && " · "}
<TonedText
text={(row.length > 1 && line.short) || line.text}
line={line}
/>
</span>
))}
</span>
<span className="ms-0.5 flex w-3 shrink-0">
{rowIndex === 0 && <QuotaBreakdownChevron />}
</span>
{resetSlot(row)}
</span>
))}
</QuotaBreakdownRow>
</span>
</div>
);
}
/**
* 有一段能点开明细时(「每周 64% · 重置 3 次 ⌄」):那一段单独做成下拉,其余照旧点了重查。
* 按钮里不能再套按钮,所以整列拆成几块:第一块刷新是真按钮(键盘、读屏都认它),
+4
View File
@@ -4462,6 +4462,10 @@
"text": "Credits ≈ {{usd}}",
"usd": "≈ {{usd}}",
"detail": "Credits balance {{balance}} · ≈ {{usd}} at API prices · used only after your limits run out"
},
"windows": {
"title": "All limits",
"showAll": "Show all limits"
}
},
"appMatrix": {
+4
View File
@@ -4462,6 +4462,10 @@
"text": "Credits 約 {{usd}}",
"usd": "約 {{usd}}",
"detail": "Credits 残高 {{balance}} · API 価格換算で約 {{usd}} · 上限に達した後にのみ消費"
},
"windows": {
"title": "各枠の残量",
"showAll": "各枠の残量を表示"
}
},
"appMatrix": {
+4
View File
@@ -4463,6 +4463,10 @@
"text": "Credits 約 {{usd}}",
"usd": "約 {{usd}}",
"detail": "Credits 餘額 {{balance}} · 按 API 價折算約 {{usd}} · 額度用完後才扣"
},
"windows": {
"title": "各檔額度",
"showAll": "查看各檔額度"
}
},
"appMatrix": {
+4
View File
@@ -4462,6 +4462,10 @@
"text": "Credits 约 {{usd}}",
"usd": "约 {{usd}}",
"detail": "Credits 余额 {{balance}} · 按 API 价折算约 {{usd}} · 额度用完后才扣"
},
"windows": {
"title": "各档额度",
"showAll": "查看各档额度"
}
},
"appMatrix": {
@@ -208,7 +208,7 @@ describe("Antigravity quota groups", () => {
it("keeps one line per group on the card: the window with the least left", () => {
renderQuota(agyTiers);
const lines = screen.getByRole("button").children;
const lines = screen.getByRole("button", { name: "查看各档额度" }).children;
expect(lines).toHaveLength(2);
expect(lines[0]).toHaveTextContent("Gemini 剩余 60%");
expect(lines[1]).toHaveTextContent("Claude/GPT 剩余 5%");
@@ -216,6 +216,34 @@ describe("Antigravity quota groups", () => {
expect(screen.getByText("2d12h")).toBeInTheDocument();
});
it("opens one dropdown listing all four windows; ↻ still refreshes", async () => {
const refetch = vi.fn();
renderQuota(agyTiers, true, {}, refetch);
fireEvent.click(screen.getByRole("button", { name: "查看各档额度" }));
expect(refetch).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { name: "各档额度" });
expect(
within(dialog)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"Gemini 5 小时剩余 90%",
"Gemini 每周2d12h后重置剩余 60%",
"Claude/GPT 5 小时剩余 5%",
"Claude/GPT 每周剩余 80%",
]);
fireEvent.click(screen.getByRole("button", { name: "点击重新查询" }));
expect(refetch).toHaveBeenCalledTimes(1);
});
it("keeps other cards' quota column clickable to refresh", () => {
renderQuota([{ name: "five_hour", utilization: 10, resetsAt: null }]);
expect(
screen.queryByRole("button", { name: "查看各档额度" }),
).not.toBeInTheDocument();
});
it("lists every group and window when expanded", () => {
renderQuota(agyTiers, false);
for (const name of [