From 468fa8c4eca24b2a871e0afceb1cd6f9c670c63c Mon Sep 17 00:00:00 2001 From: blazz Date: Wed, 5 Aug 2026 08:37:25 -0400 Subject: [PATCH] feat(codex): add Codex accounts panel in provider settings Render the ADR 0003 accounts panel inside the Codex provider detail pane: list accounts with usage bars, add/switch/fetch/remove actions, and the restart-desktop affordance, plus its dedicated styles. --- apps/desktop-tauri/src/styles.css | 38 +++ .../settings/providers/ProviderDetailPane.tsx | 2 + .../credentials/CodexAccountsSection.test.tsx | 142 ++++++++ .../credentials/CodexAccountsSection.tsx | 303 ++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 77d337fd82..29c7f1c978 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2446,6 +2446,44 @@ body:has(.tray-panel-reveal) { font-size: 0.82rem; } +/* ── Codex multi-account (ADR 0003) ─────────────────────────────────── */ + +.provider-detail-note { + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.3); + color: var(--provider-status-ok, #4ade80); + border-radius: 8px; + padding: 8px 10px; + margin-bottom: 12px; + font-size: 0.82rem; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.codex-accounts-list { + gap: 6px; +} + +.codex-accounts-card { + padding: 8px 10px; +} + +.codex-accounts-add { + margin-top: 8px; +} + +.codex-usage { + font-size: 0.74rem; + font-weight: 500; + color: var(--provider-row-text-secondary); +} + +.codex-usage--blocked { + color: var(--provider-status-error); +} + .provider-detail-pace__stage { font-size: 0.86rem; font-weight: 600; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index f69bc54bd6..996b4bdacb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -30,6 +30,7 @@ import { ChartsSection } from "./sections/charts/ChartsSection"; import { CookieSourceSection } from "./sections/CookieSourceSection"; import { RegionSection } from "./sections/RegionSection"; import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions"; +import { CodexAccountsSection } from "./sections/credentials/CodexAccountsSection"; import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; import { CookieSection } from "./CookieSection"; @@ -316,6 +317,7 @@ export function ProviderDetailPane({ /> {detail.id === "codex" && } + {detail.id === "codex" && } ({ + getCodexAccountsState: vi.fn(), + codexAccountAdd: vi.fn(), + codexAccountFetch: vi.fn(), + codexAccountRemove: vi.fn(), + codexAccountSwitch: vi.fn(), + codexAccountRestartDesktop: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ + listen: vi.fn(() => Promise.resolve(() => {})), +})); + +vi.mock("../../../../../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/api/event", () => eventMocks); + +import { CodexAccountsSection } from "./CodexAccountsSection"; + +const t = (key: string) => key; + +function account(id: string, extra: Partial = {}): 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, plan = "free"): CodexAccountUsageSnapshot { + return { + email: "user@example.com", + providerAccountId: null, + plan, + allowed: true, + limitReached: false, + primaryWindow: { usedPercent, resetAt: null, limitWindowSeconds: 3600 }, + secondaryWindow: null, + credits: null, + updatedAt: "2024-01-01T00:00:00Z", + }; +} + +describe("CodexAccountsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing before the store loads, then lists accounts", async () => { + tauriMocks.getCodexAccountsState.mockResolvedValue( + { accounts: [account("1"), account("2", { source: "ambient" })], snapshots: {} } as CodexAccountsStateBridge, + ); + const { container } = render(); + expect(container.querySelector(".codex-accounts")).toBeNull(); + + await waitFor(() => { + expect(screen.getByText("user-1@example.com")).toBeDefined(); + }); + expect(screen.getByText("user-2@example.com")).toBeDefined(); + expect(screen.getByText("CodexAccountsSourceManaged")).toBeDefined(); + expect(screen.getByText("CodexAccountsSourceAmbient")).toBeDefined(); + }); + + it("shows the usage pill and blocked state from a snapshot", async () => { + tauriMocks.getCodexAccountsState.mockResolvedValue( + { + accounts: [account("1")], + snapshots: { + "1": snapshot(38), + }, + } as CodexAccountsStateBridge, + ); + render(); + await waitFor(() => { + expect(screen.getByText("free · 38%")).toBeDefined(); + }); + }); + + it("adds an account and reloads", async () => { + tauriMocks.getCodexAccountsState.mockResolvedValueOnce( + { accounts: [], snapshots: {} } as CodexAccountsStateBridge, + ); + tauriMocks.getCodexAccountsState.mockResolvedValueOnce( + { accounts: [account("1")], snapshots: {} } as CodexAccountsStateBridge, + ); + render(); + await waitFor(() => { + expect(screen.getByText("CodexAccountsAddButton")).toBeDefined(); + }); + + tauriMocks.codexAccountAdd.mockResolvedValue(account("1")); + await act(async () => { + screen.getByText("CodexAccountsAddButton").click(); + }); + await waitFor(() => { + expect(screen.getByText("user-1@example.com")).toBeDefined(); + }); + expect(tauriMocks.codexAccountAdd).toHaveBeenCalledTimes(1); + }); + + it("switches an account and offers a desktop restart when a session can be restored", async () => { + tauriMocks.getCodexAccountsState.mockResolvedValue( + { accounts: [account("1")], snapshots: {} } as CodexAccountsStateBridge, + ); + tauriMocks.codexAccountSwitch.mockResolvedValue( + { desktopSessionRestoreExists: true, desktopSessionRestorePath: "C:/s", desktopSessionBackupPath: null } as CodexSwitchResult, + ); + render(); + await waitFor(() => { + expect(screen.getByText("CodexAccountsSwitchButton")).toBeDefined(); + }); + + await act(async () => { + screen.getByText("CodexAccountsSwitchButton").click(); + }); + await waitFor(() => { + expect(screen.getByText(/CodexSwitchSuccess/)).toBeDefined(); + }); + expect(screen.getByText(/CodexSwitchRestartPrompt/)).toBeDefined(); + + await act(async () => { + screen.getByText("CodexAccountsRestartDesktop").click(); + }); + expect(tauriMocks.codexAccountRestartDesktop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx new file mode 100644 index 0000000000..b59332530f --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import type { + CodexAccount, + CodexAccountsStateBridge, + CodexAccountUsageSnapshot, + CodexSwitchResult, +} from "../../../../../types/bridge"; +import type { LocaleKey } from "../../../../../i18n/keys"; +import { + codexAccountAdd, + codexAccountFetch, + codexAccountRemove, + codexAccountRestartDesktop, + codexAccountSwitch, + getCodexAccountsState, +} from "../../../../../lib/tauri"; + +interface Props { + t: (key: LocaleKey) => string; +} + +/** + * Inline "Codex Accounts" surface shown inside the Settings → Providers → + * Codex detail pane. + * + * Multi-account Codex support (ADR 0003). Reads the shared account + + * snapshot store via `get_codex_accounts_state` and drives the + * `codex_account_*` IPC surface: add (login into a managed home), switch the + * active ambient identity, refresh per-account usage, and remove managed + * homes. For MSIX Codex Desktop installs a restart action is offered when a + * session snapshot is available to restore. + */ +export function CodexAccountsSection({ t }: Props) { + const [accounts, setAccounts] = useState([]); + const [snapshots, setSnapshots] = useState< + Record + >({}); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [switchResult, setSwitchResult] = useState( + null, + ); + + const load = useCallback(async () => { + setBusy(true); + setError(null); + try { + const next: CodexAccountsStateBridge = await getCodexAccountsState(); + setAccounts(next.accounts); + setSnapshots(next.snapshots); + setLoaded(true); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + // Live-refresh after the provider engine runs the per-account lanes + // (ADR 0003 multi-account refresh) so the panel stays current. + useEffect(() => { + let cancelled = false; + const unlistenPromise = listen("codex-accounts-updated", () => { + if (!cancelled) void load(); + }); + return () => { + cancelled = true; + void unlistenPromise.then((fn) => fn()); + }; + }, [load]); + + const handleAdd = async () => { + setBusy(true); + setError(null); + setSwitchResult(null); + try { + await codexAccountAdd(); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const handleSwitch = async (id: string) => { + setBusy(true); + setError(null); + setSwitchResult(null); + try { + const result = await codexAccountSwitch(id); + setSwitchResult(result); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const handleFetch = async (id: string) => { + setBusy(true); + setError(null); + try { + const snapshot = await codexAccountFetch(id); + setSnapshots((prev) => ({ ...prev, [id]: snapshot })); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const handleRemove = async (id: string) => { + setBusy(true); + setError(null); + setSwitchResult(null); + try { + await codexAccountRemove(id); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const handleRestartDesktop = async () => { + if (!switchResult) return; + setBusy(true); + setError(null); + try { + await codexAccountRestartDesktop( + null, + switchResult.desktopSessionBackupPath ?? null, + switchResult.desktopSessionRestorePath ?? null, + ); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + if (!loaded) { + return null; + } + + return ( +
+
+

{t("CodexAccountsTitle")}

+ {accounts.length === 0 && ( + + )} +
+

{t("CodexAccountsHint")}

+ + {error && ( +
+ {error} +
+ )} + + {switchResult && ( +
+ {t("CodexSwitchSuccess")} + {switchResult.desktopSessionRestoreExists && ( + <> + {" "} + {t("CodexSwitchRestartPrompt")}{" "} + + + )} +
+ )} + + {accounts.length === 0 ? ( +

{t("CodexAccountsEmpty")}

+ ) : ( + <> +
    + {accounts.map((account) => { + const snapshot = snapshots[account.id]; + return ( +
  • +
    +
    + + {account.nickname ?? + account.emailHint ?? + account.authSubject ?? + shrink(account.id)} + + + + {account.source === "ambient" + ? t("CodexAccountsSourceAmbient") + : t("CodexAccountsSourceManaged")} + + {snapshot ? ( + + ) : ( + + {t("CodexAccountsUsageUnavailable")} + + )} + +
    +
    + + + +
    +
    +
  • + ); + })} +
+
+ +
+ + )} +
+ ); +} + +function shrink(id: string): string { + return id.length <= 12 ? id : `${id.slice(0, 8)}…`; +} + +function CodexUsagePill({ + snapshot, + t, +}: { + snapshot: CodexAccountUsageSnapshot; + t: (key: LocaleKey) => string; +}) { + const window = snapshot.primaryWindow; + const percent = window ? Math.round(window.usedPercent) : null; + const plan = snapshot.plan ?? ""; + const blocked = snapshot.allowed === false || snapshot.limitReached === true; + const label = [plan, percent !== null ? `${percent}%` : null] + .filter(Boolean) + .join(" · "); + return ( + + {label || t("CodexAccountsUsageUnavailable")} + + ); +} \ No newline at end of file