diff --git a/docs/design.md b/docs/design.md
index 67c0f37..df8c345 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -84,7 +84,9 @@ the dialog; the account grid remains the primary workspace.
- After a verified switch, update the toolbar identity and active card directly.
That state is the visible confirmation; do not insert a success banner above
the account grid or reload the whole workspace before showing it. Other brief
- success messages may float without moving the grid and dismiss themselves.
+ success and information messages use one compact floating notice without
+ moving the grid and dismiss themselves. Errors stay until dismissed. Focused
+ dialogs cover this notice; recovery and update actions use their own surfaces.
- Opening a dialog moves keyboard focus inside it. Tab stays inside, and closing
restores focus to the launching control. A dialog cannot be dismissed while
its non-cancellable action is in progress.
@@ -111,7 +113,8 @@ the dialog; the account grid remains the primary workspace.
account will be updated; a mismatched login changes no account.
- Keep card content compact. Truncated account and workspace names expose their
full value on hover; do not reserve empty vertical space for the old heading
- reminder.
+ reminder. Quota reset timing shows only a short relative time in the card;
+ hover, keyboard focus, and assistive text provide the exact date and time.
- Keep pasted credentials and API keys in transient, non-persistent inputs and
clear them immediately after submission.
- File import reads the selected path in Rust rather than copying file contents
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 94112cf..4cda4a6 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -570,10 +570,61 @@ describe("GSwitch account workspace", () => {
const card = (await screen.findByRole("heading", { name: "person@example.com" })).closest(".account-card");
expect(card).toHaveTextContent("5 weeks");
expect(card).toHaveTextContent("100%");
- expect(card).toHaveTextContent("Resets");
+ const resetTime = card?.querySelector("time.quota-reset-time");
+ expect(resetTime).toHaveTextContent(/in \d+ days/);
+ expect(resetTime).toHaveAttribute("aria-label", expect.stringContaining("Resets"));
+ expect(resetTime).toHaveAttribute("data-full-time", expect.any(String));
+ expect(card).not.toHaveTextContent(/Resets \d/);
expect(card).not.toHaveTextContent("5-hour");
expect(card).not.toHaveTextContent("Reset time unavailable");
});
+
+ it("shows a short Chinese reset time with keyboard access to the exact date", async () => {
+ Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" });
+ const resetAt = Math.floor(Date.now() / 1000) + 5 * 60 * 60;
+ mocks.listAccounts.mockResolvedValue([chatAccount]);
+ mocks.accountQuota.mockResolvedValue({
+ account_id: chatAccount.id,
+ status: "fresh",
+ snapshot: {
+ fetched_at_unix_ms: Date.now(),
+ buckets: [{
+ limit_id: "codex",
+ kind: "codex",
+ windows: [{ kind: "five_hour", window_duration_mins: 300, remaining_percent: 50, used_percent: 50, resets_at: resetAt }],
+ }],
+ },
+ });
+ render();
+
+ const card = (await screen.findByRole("heading", { name: "person@example.com" })).closest(".account-card");
+ const resetTime = card?.querySelector("time.quota-reset-time");
+ expect(resetTime).toHaveTextContent("5小时后");
+ expect(resetTime).toHaveAttribute("aria-label", expect.stringContaining("重置于"));
+ expect(resetTime).toHaveAttribute("tabindex", "0");
+ expect(card).not.toHaveTextContent("重置于");
+ });
+
+ it("does not show a past reset time as a current countdown", async () => {
+ const resetAt = Math.floor(Date.now() / 1000) - 60;
+ mocks.listAccounts.mockResolvedValue([chatAccount]);
+ mocks.accountQuota.mockResolvedValue({
+ account_id: chatAccount.id,
+ status: "fresh",
+ snapshot: {
+ fetched_at_unix_ms: Date.now(),
+ buckets: [{
+ limit_id: "codex",
+ kind: "codex",
+ windows: [{ kind: "five_hour", window_duration_mins: 300, remaining_percent: 50, used_percent: 50, resets_at: resetAt }],
+ }],
+ },
+ });
+ render();
+
+ const card = (await screen.findByRole("heading", { name: "person@example.com" })).closest(".account-card");
+ expect(card?.querySelector("time.quota-reset-time")).toHaveTextContent("Reset time passed");
+ });
it("uses ChatGPT email as the primary identity and workspace as context", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
render();
diff --git a/src/App.tsx b/src/App.tsx
index 2e262cf..42245df 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -38,9 +38,11 @@ import {
import { api, type UpdateDelivery } from "./api";
import {
createTranslator,
+ formatDateTime,
formatDateTimeWithRelative,
formatNumber,
formatPercent,
+ formatRelativeTime,
readLanguagePreference,
resolveLocale,
saveLanguagePreference,
@@ -438,6 +440,34 @@ function Modal({
);
}
+function QuotaResetTime({ timestamp, t, formatLocale }: {
+ timestamp: number;
+ t: Translator;
+ formatLocale: string;
+}) {
+ const [nowMs, setNowMs] = useState(Date.now);
+ useEffect(() => {
+ const interval = window.setInterval(() => setNowMs(Date.now()), 60_000);
+ return () => window.clearInterval(interval);
+ }, []);
+
+ const fullTime = formatDateTime(timestamp, formatLocale);
+ const passed = timestamp * 1000 <= nowMs;
+ return (
+
+ );
+}
+
function QuotaMeter({
label,
window,
@@ -502,7 +532,7 @@ function QuotaMeter({
: status === "stale"
? t("quota.lastResultStale")
: window?.resets_at
- ? t("quota.resets", { time: formatDateTimeWithRelative(window.resets_at, formatLocale) })
+ ?
: t("quota.resetTimeUnavailable")}
@@ -836,10 +866,10 @@ export default function App() {
const t = useMemo(() => createTranslator(locale.language), [locale.language]);
useEffect(() => {
- if (notice?.kind !== "success") return;
+ if (!notice || notice.kind === "error") return;
const timeout = window.setTimeout(() => {
setNotice((current) => current === notice ? null : current);
- }, 4000);
+ }, notice.kind === "success" ? 4000 : 6000);
return () => window.clearTimeout(timeout);
}, [notice]);
diff --git a/src/i18n.ts b/src/i18n.ts
index cce5ca9..a7b3ed0 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -56,6 +56,7 @@ const en = {
"quota.notAvailable": "Not available",
"quota.lastResultStale": "Last result is stale",
"quota.resets": "Resets {time}",
+ "quota.resetTimePassed": "Reset time passed",
"quota.resetTimeUnavailable": "Reset time unavailable",
"quota.remaining": "{label} remaining",
"quota.runningCodex": "Codex is running and its active account could not be identified. The last quota result is shown; try again when the account is available.",
@@ -393,6 +394,7 @@ const zhCN: Record = {
"quota.notAvailable": "不可用",
"quota.lastResultStale": "上次结果已过期",
"quota.resets": "重置于 {time}",
+ "quota.resetTimePassed": "重置时间已过",
"quota.resetTimeUnavailable": "重置时间不可用",
"quota.remaining": "{label} 剩余量",
"quota.runningCodex": "Codex 正在运行,GSwitch 无法识别其当前账户。现显示上次额度结果;账户可用后请重试。",
diff --git a/src/styles.css b/src/styles.css
index 78dc632..7bb2e4f 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -329,11 +329,16 @@ summary:focus-visible {
}
.toast {
+ position: fixed;
+ bottom: 16px;
+ left: 16px;
+ z-index: 9;
display: flex;
- max-width: 760px;
+ width: min(380px, calc(100vw - 32px));
+ max-height: calc(100vh - 32px);
align-items: center;
gap: 9px;
- margin: 0 0 18px;
+ overflow-y: auto;
padding: 10px 12px 10px 13px;
border: 1px solid var(--border);
border-radius: 10px;
@@ -345,12 +350,6 @@ summary:focus-visible {
}
.toast-success {
- position: fixed;
- right: 20px;
- bottom: 20px;
- z-index: 20;
- width: min(380px, calc(100vw - 40px));
- margin: 0;
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
}
@@ -918,6 +917,41 @@ summary:focus-visible {
line-height: 1.35;
}
+.quota-reset-time {
+ position: relative;
+ display: inline-block;
+ border-radius: 2px;
+ cursor: help;
+}
+
+.quota-reset-time:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.quota-reset-time::after {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 0;
+ z-index: 8;
+ width: max-content;
+ max-width: min(250px, 70vw);
+ padding: 7px 9px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ color: var(--text);
+ background: var(--surface);
+ box-shadow: var(--shadow-soft);
+ content: attr(data-full-time);
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+}
+
+.quota-meter:last-child .quota-reset-time::after { right: 0; left: auto; }
+.quota-reset-time:hover::after,
+.quota-reset-time:focus-visible::after { opacity: 1; visibility: visible; }
+
.credit-row {
min-height: 37px;
justify-content: space-between;