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
136 changes: 136 additions & 0 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
CodexAccount,
CodexAccountsStateBridge,
CodexAccountUsageSnapshot,
} from "../types/bridge";
import { buildBundle } from "../test/localeHarness";
import { LocaleProvider } from "../i18n/LocaleProvider";

const tauriMocks = vi.hoisted(() => ({
getCodexAccountsState: vi.fn(),
codexAccountSwitch: vi.fn(),
refreshProviders: vi.fn(),
getLocaleStrings: vi.fn(),
}));

const eventMocks = vi.hoisted(() => ({
listen: vi.fn(() => Promise.resolve(() => {})),
}));

vi.mock("../lib/tauri", () => tauriMocks);
vi.mock("@tauri-apps/api/event", () => eventMocks);

import CodexAccountsMenu from "./CodexAccountsMenu";

function account(id: string, extra: Partial<CodexAccount> = {}): CodexAccount {
return {
id,
nickname: null,
emailHint: `user-${id}@example.com`,
authSubject: null,
providerAccountId: null,
codexHomePath: `C:/fake/${id}`,
source: "managedByApp",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
lastAuthenticatedAt: null,
...extra,
};
}

function snapshot(usedPercent: number): CodexAccountUsageSnapshot {
return {
email: "user@example.com",
providerAccountId: null,
plan: "free",
allowed: true,
limitReached: false,
primaryWindow: { usedPercent, resetAt: null, limitWindowSeconds: 3600 },
secondaryWindow: null,
credits: null,
updatedAt: "2024-01-01T00:00:00Z",
};
}

// Wrap the component so the `t` from useLocale is a stable identity that just
// returns the key (the component uses `t(key)` for locale strings and a badge
// label; returning the key is enough to assert rendering).
function renderMenu(hideEmail: boolean, state: CodexAccountsStateBridge) {
tauriMocks.getCodexAccountsState.mockResolvedValue(state);
tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({}));
return render(
<LocaleProvider>
<CodexAccountsMenu hideEmail={hideEmail} />
</LocaleProvider>,
);
}

describe("CodexAccountsMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders nothing for a single-account setup (single-account fallback)", async () => {
const { container } = renderMenu(false, {
accounts: [account("1", { source: "ambient" })],
snapshots: {},
});
await waitFor(() => {
expect(
container.querySelector(".codex-menu-accounts"),
).toBeNull();
});
});

it("lists multiple accounts with usage bars and marks the ambient one active", async () => {
const { container } = renderMenu(false, {
accounts: [
account("1", { source: "ambient" }),
account("2"),
],
snapshots: { "1": snapshot(30), "2": snapshot(70) },
});
await screen.findByText("user-1@example.com");
expect(screen.getByText("user-2@example.com")).toBeDefined();

const rows = container.querySelectorAll(".codex-menu-accounts__row");
expect(rows.length).toBe(2);
// Ambient row is marked active; its switch is disabled.
expect(
rows[0].className.includes("codex-menu-accounts__row--active"),
).toBe(true);
expect(
(rows[0].querySelector(".codex-menu-accounts__switch") as HTMLButtonElement)
.disabled,
).toBe(true);

// Usage bar widths map to the snapshot percentages.
const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill");
expect((fills[0] as HTMLElement).style.width).toBe("30%");
expect((fills[1] as HTMLElement).style.width).toBe("70%");
});

it("switches an account and kicks a provider refresh", async () => {
renderMenu(false, {
accounts: [account("1", { source: "ambient" }), account("2")],
snapshots: {},
});
await screen.findByText("user-1@example.com");

tauriMocks.codexAccountSwitch.mockResolvedValue({});
tauriMocks.getCodexAccountsState.mockResolvedValue({
accounts: [account("1", { source: "ambient" }), account("2")],
snapshots: {},
});
const switchButtons = screen.getAllByText("CodexAccountsSwitchButton");
const activeSwitch = switchButtons.find((b) => !(b as HTMLButtonElement).disabled);
expect(activeSwitch).toBeDefined();
await act(async () => {
activeSwitch!.click();
});
expect(tauriMocks.codexAccountSwitch).toHaveBeenCalledWith("2");
expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1);
});
});
148 changes: 148 additions & 0 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type {
CodexAccount,
CodexAccountsStateBridge,
CodexAccountUsageSnapshot,
} from "../types/bridge";
import { useLocale } from "../hooks/useLocale";
import { maskEmail } from "./MenuCard";
import {
codexAccountSwitch,
getCodexAccountsState,
refreshProviders,
} from "../lib/tauri";

/**
* Multi-account lane surface for the Codex tray menu card (ADR 0003,
* option A). Renders only when more than one Codex account exists, so the
* common single-account menu stays unchanged (single-account fallback).
*
* Shows every account (ambient + managed) with a compact usage bar and a
* Switch action. Switching updates the ambient identity and triggers a
* provider refresh so the tray icon/menu reflect the now-active account.
*/
export default function CodexAccountsMenu({ hideEmail }: { hideEmail: boolean }) {
const { t } = useLocale();
const [accounts, setAccounts] = useState<CodexAccount[]>([]);
const [snapshots, setSnapshots] = useState<
Record<string, CodexAccountUsageSnapshot>
>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const load = useCallback(async () => {
setBusy(true);
setError(null);
try {
const next: CodexAccountsStateBridge = await getCodexAccountsState();
setAccounts(next.accounts);
setSnapshots(next.snapshots);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

useEffect(() => {
let cancelled = false;
const unlistenPromise = listen("codex-accounts-updated", () => {
if (!cancelled) void load();
});
return () => {
cancelled = true;
void unlistenPromise.then((fn) => fn());
};
}, [load]);

const handleSwitch = async (id: string) => {
setBusy(true);
setError(null);
try {
await codexAccountSwitch(id);
await load();
// Make the tray icon/menu reflect the newly active ambient identity.
void refreshProviders().catch(() => {});
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};

if (accounts.length <= 1) {
return null;
}

return (
<details className="codex-menu-accounts">
<summary className="codex-menu-accounts__summary">
<span className="codex-menu-accounts__title">{t("CodexAccountsTitle")}</span>
<span className="codex-menu-accounts__count">{accounts.length}</span>
</summary>
{error && (
<div className="codex-menu-accounts__error" role="alert">
{error}
</div>
)}
<ul className="codex-menu-accounts__list">
{accounts.map((account) => {
const snapshot = snapshots[account.id];
const pct = snapshot?.primaryWindow
? Math.round(snapshot.primaryWindow.usedPercent)
: null;
const label =
account.nickname ??
account.emailHint ??
account.authSubject ??
shrink(account.id);
const shown = hideEmail ? maskEmail(label) : label;
const isAmbient = account.source === "ambient";
return (
<li key={account.id}>
<div
className={`codex-menu-accounts__row${isAmbient ? " codex-menu-accounts__row--active" : ""}`}
>
<div className="codex-menu-accounts__meta">
<span className="codex-menu-accounts__email" title={label}>
{shown}
{isAmbient && (
<span className="codex-menu-accounts__badge">
{t("CodexAccountsSourceAmbient")}
</span>
)}
</span>
{pct !== null && (
<span className="codex-menu-accounts__bar" aria-hidden>
<span
className="codex-menu-accounts__bar-fill"
style={{ width: `${Math.max(2, Math.min(100, pct))}%` }}
/>
</span>
)}
</div>
<button
type="button"
className="codex-menu-accounts__switch"
disabled={busy || isAmbient}
onClick={() => void handleSwitch(account.id)}
>
{t("CodexAccountsSwitchButton")}
</button>
</div>
</li>
);
})}
</ul>
</details>
);
}

function shrink(id: string): string {
return id.length <= 12 ? id : `${id.slice(0, 8)}…`;
}
7 changes: 6 additions & 1 deletion apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { formatRelativeUpdated } from "../lib/relativeTime";
import type { LocaleKey } from "../i18n/keys";
import { providerSupportsChartData } from "../lib/providerCharts";
import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails";
import CodexAccountsMenu from "./CodexAccountsMenu";

/** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */
function CopyIconButton({ text }: { text: string }) {
Expand Down Expand Up @@ -52,7 +53,7 @@ interface MenuCardProps {
onLayoutChange?: () => void;
}

function maskEmail(email: string): string {
export function maskEmail(email: string): string {
const at = email.indexOf("@");
if (at <= 1) return "••••@••••";
return email[0] + "•".repeat(at - 1) + email.slice(at);
Expand Down Expand Up @@ -240,6 +241,10 @@ export default function MenuCard({
onLayoutChange={onLayoutChange}
/>
)}

{provider.providerId === "codex" && (
<CodexAccountsMenu hideEmail={hideEmail} />
)}
</article>
);
}
Loading
Loading