From ce6bdd299c61c09157c079acf8c081bc27e091ee Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:41:37 +0700 Subject: [PATCH] Reconcile Claude account switches before settling --- .../src-tauri/src/commands/claude_accounts.rs | 19 ++++---- .../components/ClaudeAccountsMenu.test.tsx | 48 +++++++++++++++++-- .../src/components/ClaudeAccountsMenu.tsx | 32 +++++++++++-- .../credentials/ClaudeSwapAccountsSection.tsx | 2 +- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index d4ff96ecbf..5ab1f4782b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -99,18 +99,17 @@ fn account_row_for_slot( .ok_or_else(|| "claude-swap did not report that account slot.".to_string()) } -fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String> { +async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String> { let pending = { let state = app.state::>(); let mut state = state.lock().map_err(|e| e.to_string())?; invalidate_account_usage(&mut state, ProviderId::Claude) }; crate::events::emit_provider_updated(&app, &pending); + let _emit = app.emit("claude-accounts-reconciling", ()); + let refresh_result = super::refresh_providers(app.clone()).await; changed(&app); - tauri::async_runtime::spawn(async move { - let _refresh = super::refresh_providers(app).await; - }); - Ok(()) + refresh_result } #[derive(Debug, Clone, Copy)] @@ -210,14 +209,14 @@ fn run_claude_swap_operation( } } -fn finish_claude_swap_mutation( +async fn finish_claude_swap_mutation( app: tauri::AppHandle, outcome: ClaudeSwapMutationOutcome, ) -> Result<(), String> { if !outcome.applied { return outcome.error.map_or(Ok(()), Err); } - let refresh_error = refresh_after_claude_change(app).err(); + let refresh_error = refresh_after_claude_change(app).await.err(); match (outcome.error, refresh_error) { (None, None) => Ok(()), (Some(operation_error), None) => Err(operation_error), @@ -247,7 +246,7 @@ pub async fn claude_swap_account_switch(app: tauri::AppHandle, slot: u32) -> Res .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; - finish_claude_swap_mutation(app, outcome) + finish_claude_swap_mutation(app, outcome).await } /// Re-authenticate an active slot whose current Claude credential belongs to a @@ -269,7 +268,7 @@ pub async fn claude_swap_account_reauthenticate( .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; - finish_claude_swap_mutation(app, outcome) + finish_claude_swap_mutation(app, outcome).await } fn changed(app: &tauri::AppHandle) { @@ -344,7 +343,7 @@ pub async fn claude_account_switch(app: tauri::AppHandle, id: String) -> Result< .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; drop(_credentials); - refresh_after_claude_change(app) + refresh_after_claude_change(app).await } #[cfg(test)] diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx index b403a87540..c629330feb 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx @@ -1,10 +1,22 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClaudeAccount } from "../types/bridge"; -const mocks = vi.hoisted(() => ({ claudeAccountsList: vi.fn(), claudeAccountSwitch: vi.fn(), refreshProviders: vi.fn() })); +const mocks = vi.hoisted(() => { + const listeners = new Map void>(); + return { + claudeAccountsList: vi.fn(), + claudeAccountSwitch: vi.fn(), + refreshProviders: vi.fn(), + listeners, + listen: vi.fn((event: string, callback: () => void) => { + listeners.set(event, callback); + return Promise.resolve(() => listeners.delete(event)); + }), + }; +}); vi.mock("../lib/tauri", () => mocks); -vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; @@ -13,8 +25,10 @@ const second: ClaudeAccount = { ...first, id: "second:org", email: "second@examp describe("ClaudeAccountsMenu", () => { beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); + mocks.listeners.clear(); mocks.claudeAccountsList.mockResolvedValue([first, second]); + mocks.claudeAccountSwitch.mockResolvedValue(undefined); mocks.refreshProviders.mockResolvedValue(undefined); }); @@ -39,6 +53,32 @@ describe("ClaudeAccountsMenu", () => { expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); }); + it("keeps the menu in activating and reconciling phases until the switch settles", async () => { + let resolveSwitch: (() => void) | undefined; + mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { + resolveSwitch = resolve; + })); + render(); + await screen.findByText(first.email); + const details = () => document.querySelector("details[data-claude-account-phase]") as HTMLDetailsElement; + const button = screen.getAllByText("CodexAccountsSwitchButton")[1]; + + await act(async () => fireEvent.click(button)); + expect(details().dataset.claudeAccountPhase).toBe("activating"); + expect(details()).toHaveAttribute("aria-busy", "true"); + + await act(async () => { + mocks.listeners.get("claude-accounts-reconciling")?.(); + }); + expect(details().dataset.claudeAccountPhase).toBe("reconciling"); + + await act(async () => { + resolveSwitch?.(); + }); + await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); + expect(details()).toHaveAttribute("aria-busy", "false"); + }); + it("masks emails, including tooltips, when hideEmail is enabled", async () => { mocks.claudeAccountsList.mockResolvedValue([first, { ...second, organization: `${second.email}'s Organization` }]); const { container } = render(); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx index eccc12b2b5..f47806896b 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -5,6 +5,8 @@ import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; import { maskEmail } from "./MenuCard"; +type ClaudeAccountPhase = "idle" | "activating" | "reconciling" | "settled"; + export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { hideEmail: boolean; onLayoutChange?: () => void; @@ -14,6 +16,7 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [switched, setSwitched] = useState(false); + const [phase, setPhase] = useState("idle"); const mounted = useRef(false); const load = useCallback(async () => { const next = await claudeAccountsList(); @@ -30,26 +33,40 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { reload(); window.addEventListener("focus", reload); const unlisten = listen("claude-accounts-updated", reload); + const unlistenReconciling = listen("claude-accounts-reconciling", () => { + if (mounted.current) { + setPhase("reconciling"); + setSwitched(false); + } + }); return () => { mounted.current = false; window.removeEventListener("focus", reload); void unlisten.then(fn => fn()).catch(() => {}); + void unlistenReconciling.then(fn => fn()).catch(() => {}); }; }, [load]); useEffect(() => { onLayoutChange?.(); - }, [accounts.length, error, switched, onLayoutChange]); + }, [accounts.length, error, phase, switched, onLayoutChange]); const switchAccount = async (id: string) => { setBusy(true); + setPhase("activating"); setError(null); setSwitched(false); try { await claudeAccountSwitch(id); await load(); - if (mounted.current) setSwitched(true); + if (mounted.current) { + setPhase("settled"); + setSwitched(true); + } } catch (e) { - if (mounted.current) setError(String(e)); + if (mounted.current) { + setPhase("idle"); + setError(String(e)); + } } finally { if (mounted.current) setBusy(false); } @@ -58,7 +75,12 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const hasSwitchableAccount = accounts.some(account => account.isSaved && !account.isActive); if (accounts.length <= 1 && !hasSwitchableAccount && !error) return null; return ( -
+
{t("ClaudeAccountsTitle")} {accounts.length} @@ -83,7 +105,7 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: {