Skip to content
Open
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
19 changes: 9 additions & 10 deletions apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Mutex<AppState>>();
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)]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)]
Expand Down
48 changes: 44 additions & 4 deletions apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, () => 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";

Expand All @@ -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);
});

Expand All @@ -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<void>(resolve => {
resolveSwitch = resolve;
}));
render(<ClaudeAccountsMenu hideEmail={false} />);
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(<ClaudeAccountsMenu hideEmail />);
Expand Down
32 changes: 27 additions & 5 deletions apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +16,7 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [switched, setSwitched] = useState(false);
const [phase, setPhase] = useState<ClaudeAccountPhase>("idle");
const mounted = useRef(false);
const load = useCallback(async () => {
const next = await claudeAccountsList();
Expand All @@ -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);
}
Expand All @@ -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 (
<details className="codex-menu-accounts" onToggle={onLayoutChange}>
<details
className="codex-menu-accounts"
data-claude-account-phase={phase}
aria-busy={phase === "activating" || phase === "reconciling"}
onToggle={onLayoutChange}
>
<summary className="codex-menu-accounts__summary">
<span className="codex-menu-accounts__title">{t("ClaudeAccountsTitle")}</span>
<span className="codex-menu-accounts__count">{accounts.length}</span>
Expand All @@ -83,7 +105,7 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: {
<button
type="button"
className="codex-menu-accounts__switch"
disabled={busy || account.isActive || !account.isSaved}
disabled={busy || phase === "activating" || phase === "reconciling" || account.isActive || !account.isSaved}
onClick={() => void switchAccount(account.id)}
>
{t("CodexAccountsSwitchButton")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) {
} else {
throw new Error("This claude-swap account is not actionable.");
}
// The backend emits `claude-accounts-updated` after invalidating usage;
// The backend emits `claude-accounts-updated` after reconciliation;
// the listener above performs the single reload.
if (mounted.current) {
setMessage(
Expand Down