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;