Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,16 @@ is installed without a click.
notice because the card itself updates. Each card shows how long ago its
quota was read next to its refresh button. Automatic reads leave switching
available while waiting for the provider and never refresh a saved
credential. A failed card keeps a visible,
compact warning icon beside quota;
its hover/focus explanation gives the safe reason and last successful update.
Old percentages say "Last" and use muted meters; an unavailable result stays
unknown. Actions stay in the account menu, not in the warning tooltip. Batch
feedback describes quota results only.
credential. A failed card has one compact warning icon beside its first quota
title, with no permanent failure sentence or stale badge. Hover, keyboard
focus, or click opens the safe reason and last successful update; Escape,
focus leaving, or an outside click closes it. Old percentages say "Last" and
use muted meters without reset countdowns or replacement explanation lines.
An unavailable result stays unknown. Stale data without a failure says
"Quota awaiting update" in its details rather than claiming a failed refresh.
A successful refresh clears the warning and historical labels. Actions stay
in the account menu, not in the warning details. Batch feedback describes
quota results only.
- A saved email can be copied from its card menu. Every saved ChatGPT account
also keeps **Sign in again** in that menu, so recovery never depends on
GSwitch diagnosing a failure correctly. After a confirmed authentication
Expand All @@ -161,7 +165,7 @@ is installed without a click.
such as `4h 55m · 09/27 16:30` or `4小时55分 · 09/27 16:30`, without a visible
introductory label or trailing “in/后”. Show the year only across years and
allow wrapping at the separator on narrow cards without truncating the date.
Stale data shows its stale state instead of a live countdown; an elapsed
Stale data omits the live countdown; an elapsed
fresh reset marker says to refresh. Hover, keyboard focus, and assistive
text provide the full reset meaning and date.
Reset credits use two aligned lines: count and action above, compact relative
Expand Down
10 changes: 9 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,15 @@ as applicable:
"updated" age;
- a one-account quota refresh updating its own projection without rebuilding
the workspace, and that account's busy state leaving unrelated cards usable;
- quota bucket normalization, zero remaining, and stale-cache labeling;
- quota bucket normalization and zero remaining;
- cached and uncached refresh failures showing at most one quota warning per
card, historical percentages and muted meters without a stale badge or
countdown, safe failure details and the last successful time, hover/click/
keyboard access and Escape/blur/outside dismissal, stale data without a
failure described as awaiting an update, authentication recovery, and a
successful retry clearing the warning and historical labels;
- reopening with a successful reading older than five minutes keeping its
normal quota presentation and existing refresh policy;
- a quota projection failure leaving the saved-account workspace and Switch
action available;
- provider refresh before reset-credit selection;
Expand Down
111 changes: 107 additions & 4 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ describe("GSwitch account workspace", () => {
render(<App />);

expect(await screen.findByText("Personal")).toBeInTheDocument();
expect(screen.getByText("Stale")).toBeInTheDocument();
expect(screen.queryByText("Stale")).not.toBeInTheDocument();
expect(screen.getByText("Last 30%")).toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Details" }));
Expand Down Expand Up @@ -1192,7 +1192,7 @@ describe("GSwitch account workspace", () => {
);
expect(await screen.findByText("Could not refresh quota for 1 of your accounts. See the affected cards for their latest result.")).toBeInTheDocument();
const failedCard = screen.getByRole("heading", { name: "person3@example.com" }).closest(".account-card");
expect(failedCard).toHaveTextContent(/Last result is stale|Not available/);
expect(failedCard).not.toHaveTextContent("Last result is stale");
expect(within(failedCard as HTMLElement).getByRole("button", { name: "Quota update failed for person3@example.com" })).toBeInTheDocument();
expect(failedCard).toHaveTextContent("GSwitch could not update this quota. Try again later.");
expect(failedCard).toHaveTextContent("Last 30%");
Expand All @@ -1210,8 +1210,12 @@ describe("GSwitch account workspace", () => {
const alert = await screen.findByRole("button", { name: "Quota update failed for person@example.com" });
expect(alert).toHaveAttribute("aria-describedby", "quota-error-account-1");
expect(screen.getByText("Last 30%")).toBeInTheDocument();
expect(screen.getByText("This saved sign-in was rejected. Sign in to this account again.")).toBeInTheDocument();
expect(screen.getByText(/Last successful update:/)).toBeInTheDocument();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
await userEvent.click(alert);
const details = screen.getByRole("tooltip");
expect(details).toHaveTextContent("This saved sign-in was rejected. Sign in to this account again.");
expect(details).toHaveTextContent(/Last successful update:/);
expect(within(details).queryByRole("button")).not.toBeInTheDocument();
expect(screen.queryByText("Quota could not be refreshed. The last result is shown when available.")).not.toBeInTheDocument();

await userEvent.click(screen.getByLabelText("More actions for person@example.com"));
Expand All @@ -1223,6 +1227,105 @@ describe("GSwitch account workspace", () => {
expect(await screen.findByRole("dialog", { name: "Sign in again · person@example.com" })).toBeInTheDocument();
});

it("shows one compact warning for a failed cached reading and opens it by hover, click, or keyboard focus", async () => {
const user = userEvent.setup();
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue(staleQuota);
mocks.refreshAccountQuota.mockRejectedValue({ code: "network", message: "private provider payload" });
render(<App />);

const warning = await screen.findByRole("button", { name: "Quota update failed for person@example.com" });
const card = warning.closest("article")!;
expect(within(card).getAllByRole("button", { name: /Quota update failed/ })).toHaveLength(1);
expect(card).toHaveTextContent("Last 30%");
expect(card).toHaveTextContent("Last 85%");
expect(card).not.toHaveTextContent(/Stale|Last result is stale|private provider payload/);
expect(card.querySelectorAll(".quota-meter small")).toHaveLength(0);
expect(within(card).queryByRole("progressbar")).not.toBeInTheDocument();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

await user.hover(warning);
expect(screen.getByRole("tooltip")).toHaveTextContent("The quota service could not be reached. Check your connection.");
await user.unhover(warning);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

await user.click(warning);
expect(screen.getByRole("tooltip")).toHaveTextContent(/Last successful update:/);
await user.keyboard("{Escape}");
expect(warning).toHaveFocus();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
await user.click(warning);
expect(screen.getByRole("tooltip")).toBeInTheDocument();
await user.tab();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
await user.tab({ shift: true });
expect(warning).toHaveFocus();
expect(screen.getByRole("tooltip")).toBeInTheDocument();
await user.click(screen.getByRole("heading", { name: "GSwitch" }));
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

mocks.refreshAccountQuota.mockResolvedValue({ ...staleQuota, status: "fresh" });
await user.click(screen.getByRole("button", { name: "Refresh person@example.com" }));
expect(await within(card).findByText("30%")).toBeInTheDocument();
expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument();
expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument();
});

it("describes stale data without a failure as awaiting an update and omits an invalid last-success time", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue({
...staleQuota,
snapshot: { ...staleQuota.snapshot!, fetched_at_unix_ms: 0 },
});
mocks.refreshAccountQuota.mockImplementation(() => new Promise<QuotaView>(() => undefined));
render(<App />);

const warning = await screen.findByRole("button", { name: "Quota awaiting update for person@example.com" });
expect(screen.queryByRole("button", { name: /Quota update failed/ })).not.toBeInTheDocument();
await userEvent.click(warning);
const details = screen.getByRole("tooltip");
expect(details).toHaveTextContent("Quota awaiting update");
expect(details).not.toHaveTextContent(/failed|Last successful update/);
expect(screen.getByText("Last 30%")).toBeInTheDocument();
});

it("keeps a failed quota without a cache unknown and clears its warning after a successful retry", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.refreshAccountQuota.mockRejectedValueOnce({ code: "network" });
render(<App />);

const warning = await screen.findByRole("button", { name: "Quota update failed for person@example.com" });
const card = warning.closest("article")!;
expect(within(card).getByText("Not available")).toBeInTheDocument();
expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument();
expect(card.querySelector(".quota-heading > strong")).toHaveTextContent("—");
await userEvent.click(warning);
expect(screen.getByRole("tooltip")).not.toHaveTextContent("Last successful update");

mocks.refreshAccountQuota.mockResolvedValue({ ...staleQuota, status: "fresh" });
await userEvent.click(screen.getByRole("button", { name: "Refresh person@example.com" }));
expect(await within(card).findByText("30%")).toBeInTheDocument();
expect(within(card).getAllByRole("progressbar")).toHaveLength(2);
expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument();
expect(within(card).queryByText(/^Last \d/)).not.toBeInTheDocument();
});

it("does not turn a successful reading older than five minutes into stale data on reopening", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue({
...staleQuota,
status: "fresh",
snapshot: { ...staleQuota.snapshot!, fetched_at_unix_ms: Date.now() - 10 * 60_000 },
});
render(<App />);

expect(await screen.findByText("30%")).toBeInTheDocument();
expect(screen.getAllByRole("progressbar")).toHaveLength(2);
expect(screen.queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument();
expect(screen.queryByText(/Last 30%|Last result is stale|Stale/)).not.toBeInTheDocument();
expect(mocks.refreshAccountQuota).not.toHaveBeenCalled();
});

it("shows a direct recovery action with the saved email and workspace", async () => {
mocks.listAccounts.mockResolvedValue([{ ...chatAccount, sign_in_required: true }]);
mocks.accountQuota.mockResolvedValue(staleQuota);
Expand Down
95 changes: 73 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,72 @@ function QuotaResetTime({ timestamp, t, formatLocale }: {
);
}

function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }: {
accountName: string;
failure?: QuotaRefreshFailureCode;
id: string;
lastSuccess?: number;
formatLocale: string;
t: Translator;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);

useEffect(() => {
if (!open) return;
const closeOutside = (event: Event) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
};
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const close = () => setOpen(false);
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("focusin", closeOutside);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("blur", close);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("focusin", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("blur", close);
};
}, [open]);

return (
<span
className="quota-alert"
data-open={open}
ref={rootRef}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => {
if (!rootRef.current?.contains(document.activeElement)) setOpen(false);
}}
onFocus={() => setOpen(true)}
onBlur={(event) => {
if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) setOpen(false);
}}
>
<button
aria-controls={id}
aria-describedby={id}
aria-expanded={open}
aria-label={t(failure ? "quota.refreshErrorFor" : "quota.pendingFor", { name: accountName })}
className="quota-alert-button"
onClick={() => setOpen(true)}
type="button"
><CircleAlert size={15} /></button>
<span className="quota-alert-tooltip" hidden={!open} id={id} role="tooltip">
<strong>{t(failure ? "quota.refreshErrorLabel" : "quota.pending")}</strong>
{failure ? <span>{quotaFailureMessage(t, failure)}</span> : null}
{lastSuccess && lastSuccess > 0 && lastSuccess <= Date.now() ? (
<span>{t("quota.lastUpdated", { time: formatDateTimeWithRelative(lastSuccess / 1000, formatLocale) })}</span>
) : null}
</span>
</span>
);
}

function QuotaMeter({
accountName,
label,
Expand Down Expand Up @@ -703,20 +769,8 @@ function QuotaMeter({
return (
<div className={"quota-meter" + (status === "stale" ? " quota-meter-stale" : "")}>
<div className="quota-heading">
<span className="quota-label">{label}{failure ? (
<span className="quota-alert">
<button
aria-describedby={failureId}
aria-label={t("quota.refreshErrorFor", { name: accountName })}
className="quota-alert-button"
type="button"
><CircleAlert size={15} /></button>
<span className="quota-alert-tooltip" id={failureId} role="tooltip">
<strong>{t("quota.refreshErrorLabel")}</strong>
<span>{quotaFailureMessage(t, failure)}</span>
{lastSuccess ? <span>{t("quota.lastUpdated", { time: formatDateTimeWithRelative(lastSuccess / 1000, formatLocale) })}</span> : null}
</span>
</span>
<span className="quota-label">{label}{failureId && (failure || status === "stale") ? (
<QuotaAlert accountName={accountName} failure={failure} id={failureId} lastSuccess={lastSuccess} formatLocale={formatLocale} t={t} />
) : null}</span>
<strong>{value === undefined || !known ? "—" : status === "stale"
? t("quota.previousValue", { value: formatPercent(value, formatLocale) })
Expand All @@ -735,15 +789,13 @@ function QuotaMeter({
style={{ width: String(value ?? 0) + "%" }}
/>
</div>
<small>
{!known
{status !== "stale" || value === undefined ? <small>
{!known || value === undefined
? t("quota.notAvailable")
: status === "stale"
? t("quota.lastResultStale")
: window?.resets_at
: window?.resets_at
? <QuotaResetTime timestamp={window.resets_at} formatLocale={formatLocale} t={t} />
: t("quota.resetTimeUnavailable")}
</small>
</small> : null}
</div>
);
}
Expand Down Expand Up @@ -851,7 +903,6 @@ function AccountCard({
<span className="badge">{accountPlan(account, t)}</span>
{signInRequired ? <span className="badge badge-error">{t("account.signInRequired")}</span> : null}
{!signInRequired && active && account.needs_apply ? <span className="badge badge-muted">{t("account.pendingApply")}</span> : null}
{quota?.status === "stale" ? <span className="badge badge-muted">{t("account.stale")}</span> : null}
</div>

{isApiKey ? (
Expand All @@ -869,7 +920,7 @@ function AccountCard({
<QuotaMeter
accountName={primaryName}
failure={index === 0 ? quotaFailure : undefined}
failureId={"quota-error-" + account.id}
failureId={index === 0 ? "quota-error-" + account.id : undefined}
lastSuccess={quota?.snapshot?.fetched_at_unix_ms}
formatLocale={formatLocale}
key={index}
Expand Down
10 changes: 5 additions & 5 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const en = {
"account.signInRequired": "Sign in required",
"account.pendingApply": "Ready to apply",
"account.apply": "Apply new sign-in for {name}",
"account.stale": "Stale",
"account.savedNoBillableCheck": "Saved · key not tested",
"account.apiKeyDescription": "Subscription quota, reset credits, and Wake do not apply to API-key accounts.",
"account.refresh": "Refresh {name}",
Expand All @@ -59,7 +58,8 @@ const en = {
"quota.durationHours": "{count} hours",
"quota.otherWindow": "Codex quota",
"quota.notAvailable": "Not available",
"quota.lastResultStale": "Last result is stale",
"quota.pending": "Quota awaiting update",
"quota.pendingFor": "Quota awaiting update for {name}",
"quota.resets": "Resets {time}",
"quota.resetTimePassed": "Refresh to update",
"quota.resetTimeUnavailable": "Reset time unavailable",
Expand Down Expand Up @@ -414,7 +414,6 @@ const zhCN: Record<keyof typeof en, string> = {
"account.signInRequired": "需登录",
"account.pendingApply": "待应用",
"account.apply": "应用 {name} 的新登录",
"account.stale": "已过期",
"account.savedNoBillableCheck": "已保存 · 未测试密钥",
"account.apiKeyDescription": "订阅额度、重置额度和唤醒不适用于 API 密钥账户。",
"account.refresh": "刷新 {name}",
Expand All @@ -427,8 +426,9 @@ const zhCN: Record<keyof typeof en, string> = {
"quota.durationDays": "{count} 天",
"quota.durationHours": "{count} 小时",
"quota.otherWindow": "Codex 额度",
"quota.notAvailable": "不可用",
"quota.lastResultStale": "上次结果已过期",
"quota.notAvailable": "暂无数据",
"quota.pending": "额度待更新",
"quota.pendingFor": "{name} 的额度待更新",
"quota.resets": "重置于 {time}",
"quota.resetTimePassed": "待刷新",
"quota.resetTimeUnavailable": "重置时间不可用",
Expand Down
4 changes: 2 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -858,8 +858,8 @@ summary:focus-visible {
}

.quota-alert-tooltip strong { font-size: 12px; font-weight: 700; }
.quota-alert:hover .quota-alert-tooltip,
.quota-alert:focus-within .quota-alert-tooltip { opacity: 1; visibility: visible; }
.quota-alert-tooltip[hidden] { display: none; }
.quota-alert[data-open="true"] .quota-alert-tooltip { opacity: 1; visibility: visible; }
.quota-meter-stale .quota-heading > strong { color: var(--muted); font-size: 11px; }
.quota-meter-stale .quota-track span { opacity: .45; }

Expand Down
Loading