From 609076cbdb633c44589f6b36edb4462268503f22 Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:03:39 +0800 Subject: [PATCH 1/2] Close account and language menus on outside press and Escape The card and toolbar menus were native
elements, so they stayed open until their own button was pressed again and several could be open at once. Replace both with one controlled popover that closes on an outside press, Escape, focus leaving, window blur, or a chosen action, and returns focus to its button. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 4 ++ src/App.test.tsx | 72 +++++++++++++++++++++ src/App.tsx | 161 +++++++++++++++++++++++++++++++++++------------ src/styles.css | 20 +++--- 4 files changed, 205 insertions(+), 52 deletions(-) diff --git a/docs/design.md b/docs/design.md index d069030..5d7f864 100644 --- a/docs/design.md +++ b/docs/design.md @@ -93,6 +93,10 @@ without a click. Codex CLI and the Codex desktop app update separately. - 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. +- The account and language menus are small popovers, and only one is open at a + time. Pressing outside, Escape, moving keyboard focus away, opening another + menu, or choosing an action closes the menu. Escape and a chosen action + return focus to the menu button. - Disable conflicting credential actions while one is in progress. - Show progress on the affected row or in the focused operation dialog. - Keep Wake results per account; identify each result by email and workspace, diff --git a/src/App.test.tsx b/src/App.test.tsx index aed4622..7d57c40 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1161,6 +1161,78 @@ describe("GSwitch account workspace", () => { expect(mocks.removeSavedAccount).not.toHaveBeenCalled(); }); + it("closes an account menu on an outside press, Escape, or another menu", async () => { + const otherAccount = { ...chatAccount, id: "account-2", email: "other@example.com" }; + mocks.listAccounts.mockResolvedValue([chatAccount, otherAccount]); + const user = userEvent.setup(); + render(); + const trigger = await screen.findByRole("button", { name: "More actions for person@example.com" }); + const menuItem = () => screen.queryByRole("button", { name: "Remove person@example.com" }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(menuItem()).not.toBeInTheDocument(); + + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(menuItem()).toBeInTheDocument(); + await user.click(screen.getByRole("heading", { name: "2 saved accounts" })); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(menuItem()).not.toBeInTheDocument(); + + await user.click(trigger); + await user.keyboard("{Escape}"); + expect(menuItem()).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + + await user.click(trigger); + await user.click(screen.getByRole("button", { name: "More actions for other@example.com" })); + expect(menuItem()).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove other@example.com" })).toBeInTheDocument(); + }); + + it("closes an account menu when keyboard focus leaves it or an action is chosen", async () => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + // userEvent.setup() installs its own clipboard, so stub it afterwards. + const user = userEvent.setup(); + const copyEmail = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText: copyEmail } }); + render(); + const trigger = await screen.findByRole("button", { name: "More actions for person@example.com" }); + + trigger.focus(); + await user.keyboard("{Enter}"); + await user.tab(); + expect(screen.getByRole("button", { name: "Copy email" })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole("button", { name: "Remove person@example.com" })).toHaveFocus(); + await user.tab(); + expect(screen.queryByRole("button", { name: "Copy email" })).not.toBeInTheDocument(); + + await user.click(trigger); + await user.click(screen.getByRole("button", { name: "Copy email" })); + expect(copyEmail).toHaveBeenCalledWith("person@example.com"); + expect(screen.queryByRole("button", { name: "Copy email" })).not.toBeInTheDocument(); + + await user.click(trigger); + await user.click(screen.getByRole("button", { name: "Remove person@example.com" })); + const dialog = await screen.findByRole("dialog", { name: "Remove person@example.com?" }); + expect(screen.queryByRole("button", { name: "Copy email" })).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(trigger).toHaveFocus(); + }); + + it("closes the language menu on an outside press", async () => { + const user = userEvent.setup(); + render(); + const heading = await screen.findByRole("heading", { name: "0 saved accounts" }); + const trigger = screen.getByRole("button", { name: "Language" }); + + await user.click(trigger); + expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument(); + await user.click(heading); + expect(screen.queryByRole("button", { name: "English" })).not.toBeInTheDocument(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + it("localizes the remove-operation retry prompt in Simplified Chinese", async () => { window.localStorage.setItem("gswitch.language", "zh-CN"); mocks.listAccounts.mockResolvedValue([chatAccount]); diff --git a/src/App.tsx b/src/App.tsx index bf21881..9c7cf1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -471,6 +472,90 @@ function Modal({ ); } +function PopoverMenu({ + className, + label, + title, + popoverLabel, + icon, + children, +}: { + className: string; + label: string; + title?: string; + popoverLabel?: string; + icon: ReactNode; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const popoverId = useId(); + + useEffect(() => { + if (!open) return; + const close = () => setOpen(false); + const closeOnOutsidePress = (event: PointerEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) close(); + }; + const closeOnEscape = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") return; + close(); + triggerRef.current?.focus(); + }; + document.addEventListener("pointerdown", closeOnOutsidePress); + document.addEventListener("keydown", closeOnEscape); + window.addEventListener("blur", close); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePress); + document.removeEventListener("keydown", closeOnEscape); + window.removeEventListener("blur", close); + }; + }, [open]); + + return ( +
{ + // WebKit does not focus a clicked button, so a missing target is not + // proof that focus left the menu; the pointer listener covers that. + if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) { + setOpen(false); + } + }} + > + + {open ? ( +
{ + if (!(event.target instanceof Element) || !event.target.closest("button")) return; + // Return focus first so a dialog opened by this action restores it + // to the trigger rather than to a removed menu item. + triggerRef.current?.focus(); + setOpen(false); + }} + > + {children} +
+ ) : null} +
+ ); +} + function QuotaResetTime({ timestamp, t, formatLocale }: { timestamp: number; t: Translator; @@ -648,27 +733,23 @@ function AccountCard({ {!selectionMode ? ( -
- - - -
- {account.email ? : null} - -
-
+ } + label={t("account.moreActions", { name: primaryName })} + > + {account.email ? : null} + + ) : null} @@ -1826,25 +1907,25 @@ export default function App() { {t("cli.available", { version: cliInfo.latest_version || "" })} ) : null} -
- -
- {(["system", "zh-CN", "en"] as const).map((preference) => ( - - ))} -
-
+ } + label={t("toolbar.language")} + popoverLabel={t("settings.language")} + title={t("toolbar.language")} + > + {(["system", "zh-CN", "en"] as const).map((preference) => ( + + ))} + diff --git a/src/styles.css b/src/styles.css index 66cfcaf..baf142b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -201,21 +201,19 @@ summary:focus-visible { position: relative; } -.language-menu summary { +.language-menu > button { display: grid; width: 35px; height: 35px; place-items: center; - list-style: none; + padding: 0; border: 1px solid var(--border); border-radius: 9px; color: var(--muted); background: var(--surface); - cursor: pointer; } -.language-menu summary::-webkit-details-marker { display: none; } -.language-menu summary:hover { color: var(--text); background: var(--surface-hover); } +.language-menu > button:hover { color: var(--text); background: var(--surface-hover); } .language-menu-popover { position: absolute; @@ -726,21 +724,19 @@ summary:focus-visible { flex: 0 0 auto; } -.card-menu summary { +.card-menu > button { display: grid; width: 28px; height: 28px; place-items: center; - list-style: none; + padding: 0; + border: 0; border-radius: 7px; color: var(--soft-muted); + background: transparent; } -.card-menu summary::-webkit-details-marker { - display: none; -} - -.card-menu summary:hover { +.card-menu > button:hover { color: var(--text); background: var(--surface-hover); } From 2caa66d3034fa14835100ad4e134df96b9c6313c Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:06:33 +0800 Subject: [PATCH 2/2] Keep Sign in again in every ChatGPT account menu Sign in again appeared only after a confirmed authentication failure, so an account whose failure was classified as something else had no way to start a new login. Keep the action in the account menu for every saved ChatGPT account and keep promoting it to the primary action after a confirmed failure. Table tests cover every switch and quota failure code. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 6 ++-- src/App.test.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++-- src/App.tsx | 1 + 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/design.md b/docs/design.md index 5d7f864..3a37e9b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -125,8 +125,10 @@ without a click. Codex CLI and the Codex desktop app update separately. 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. -- A saved email can be copied from its card menu. After a confirmed - authentication failure, the card shows **Sign in required** and makes +- 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 + failure, the card additionally shows **Sign in required** and makes **Sign in again** its primary action. The focused dialog shows the saved email and workspace, offers Copy email, and updates the original record only after verifying the returning identity. A saved login for the current account diff --git a/src/App.test.tsx b/src/App.test.tsx index 7d57c40..87476cd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { open } from "@tauri-apps/plugin-dialog"; import App from "./App"; -import type { AccountView, QuotaView, SwitchFailureCode } from "./types"; +import type { AccountView, QuotaRefreshFailureCode, QuotaView, SwitchFailureCode } from "./types"; const mocks = vi.hoisted(() => ({ runtimeInfo: vi.fn(), @@ -862,7 +862,7 @@ describe("GSwitch account workspace", () => { expect(mocks.appSnapshot).toHaveBeenCalledOnce(); }); - it("keeps failed quota values visibly historical and offers account recovery only for a rejected sign-in", async () => { + it("keeps failed quota values visibly historical and makes sign-in the primary action for a rejected sign-in", async () => { const copyEmail = vi.fn().mockResolvedValue(undefined); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText: copyEmail } }); mocks.listAccounts.mockResolvedValue([chatAccount]); @@ -1022,6 +1022,88 @@ describe("GSwitch account workspace", () => { ); }); + // Whether each failure makes Sign in again the card's primary action. The + // account menu must offer it after every failure, so recovery never depends + // on GSwitch having diagnosed the failure correctly. + const switchFailurePromotesSignIn = { + operation_busy: false, + codex_open: false, + account_needs_sign_in: true, + file_store_required: false, + credentials_changed: false, + recovery_required: false, + local_verification_failed: false, + codex_app_server_unavailable: false, + codex_config_unavailable: false, + codex_config_cleanup_failed: false, + current_credential_unreadable: false, + current_account_not_saved: false, + target_check_unavailable: false, + target_workspace_mismatch: false, + post_write_verification_failed: false, + verification_failed: false, + } satisfies Record; + + const quotaFailurePromotesSignIn = { + operation_busy: false, + codex_account_unknown: false, + authentication: true, + manual_refresh_needed: false, + rate_limited: false, + network: false, + service: false, + invalid_response: false, + identity_mismatch: false, + unavailable: false, + } satisfies Record; + + async function expectSignInAgainReachable(promoted: boolean) { + const card = screen.getByRole("heading", { name: "person@example.com" }).closest("article") as HTMLElement; + expect(within(card).queryAllByRole("button", { name: "Sign in again" })).toHaveLength(promoted ? 1 : 0); + expect(within(card).queryAllByRole("button", { name: "Switch to person@example.com" })).toHaveLength(promoted ? 0 : 1); + + await userEvent.click(within(card).getByRole("button", { name: "More actions for person@example.com" })); + const menu = card.querySelector(".card-menu-popover")!; + await userEvent.click(within(menu).getByRole("button", { name: "Sign in again" })); + await waitFor(() => expect(mocks.startOAuth).toHaveBeenCalledWith("account-1")); + expect(await screen.findByRole("dialog", { name: "Sign in again ยท person@example.com" })).toBeInTheDocument(); + } + + it.each(Object.entries(switchFailurePromotesSignIn) as Array<[SwitchFailureCode, boolean]>)( + "keeps Sign in again reachable after a %s switch failure", + async (code, promoted) => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.switchAccount.mockRejectedValue({ code }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Switch to person@example.com" })); + await screen.findByRole("alert"); + await expectSignInAgainReachable(promoted); + }, + ); + + it.each(Object.entries(quotaFailurePromotesSignIn) as Array<[QuotaRefreshFailureCode, boolean]>)( + "keeps Sign in again reachable after a %s quota failure", + async (code, promoted) => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue(staleQuota); + mocks.refreshAccountQuota.mockRejectedValue({ code }); + render(); + + await screen.findByRole("button", { name: "Quota update failed for person@example.com" }); + await expectSignInAgainReachable(promoted); + }, + ); + + it("does not offer browser sign-in for an API-key account", async () => { + mocks.listAccounts.mockResolvedValue([{ id: "api-1", label: "Key", kind: "api_key", active: false }]); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "More actions for Key" })); + expect(screen.getByRole("button", { name: "Remove Key" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in again" })).not.toBeInTheDocument(); + }); + it("updates the active account without reloading the workspace or adding a banner", async () => { const currentAccount: AccountView = { id: "account-2", @@ -1203,6 +1285,8 @@ describe("GSwitch account workspace", () => { await user.tab(); expect(screen.getByRole("button", { name: "Copy email" })).toHaveFocus(); await user.tab(); + expect(screen.getByRole("button", { name: "Sign in again" })).toHaveFocus(); + await user.tab(); expect(screen.getByRole("button", { name: "Remove person@example.com" })).toHaveFocus(); await user.tab(); expect(screen.queryByRole("button", { name: "Copy email" })).not.toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 9c7cf1a..c432474 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -739,6 +739,7 @@ function AccountCard({ label={t("account.moreActions", { name: primaryName })} > {account.email ? : null} + {!isApiKey ? : null}