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
7 changes: 5 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
53 changes: 52 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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(<App />);

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(<App />);
Expand Down
36 changes: 33 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ import {
import { api, type UpdateDelivery } from "./api";
import {
createTranslator,
formatDateTime,
formatDateTimeWithRelative,
formatNumber,
formatPercent,
formatRelativeTime,
readLanguagePreference,
resolveLocale,
saveLanguagePreference,
Expand Down Expand Up @@ -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 (
<time
aria-label={passed
? `${t("quota.resetTimePassed")}: ${fullTime}`
: t("quota.resets", { time: fullTime })}
className="quota-reset-time"
data-full-time={fullTime}
dateTime={new Date(timestamp * 1000).toISOString()}
tabIndex={0}
>
{passed ? t("quota.resetTimePassed") : formatRelativeTime(timestamp, formatLocale, nowMs)}
</time>
);
}

function QuotaMeter({
label,
window,
Expand Down Expand Up @@ -502,7 +532,7 @@ function QuotaMeter({
: status === "stale"
? t("quota.lastResultStale")
: window?.resets_at
? t("quota.resets", { time: formatDateTimeWithRelative(window.resets_at, formatLocale) })
? <QuotaResetTime timestamp={window.resets_at} formatLocale={formatLocale} t={t} />
: t("quota.resetTimeUnavailable")}
</small>
</div>
Expand Down Expand Up @@ -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]);

Expand Down
2 changes: 2 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -393,6 +394,7 @@ const zhCN: Record<keyof typeof en, string> = {
"quota.notAvailable": "不可用",
"quota.lastResultStale": "上次结果已过期",
"quota.resets": "重置于 {time}",
"quota.resetTimePassed": "重置时间已过",
"quota.resetTimeUnavailable": "重置时间不可用",
"quota.remaining": "{label} 剩余量",
"quota.runningCodex": "Codex 正在运行,GSwitch 无法识别其当前账户。现显示上次额度结果;账户可用后请重试。",
Expand Down
50 changes: 42 additions & 8 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}

Expand Down Expand Up @@ -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;
Expand Down
Loading