From 8cc1332bc7c5209e51e04d07d1e59e5f21736760 Mon Sep 17 00:00:00 2001 From: blazz Date: Wed, 5 Aug 2026 09:01:56 -0400 Subject: [PATCH] feat(codex): add tray menu flyout for Codex accounts Render an expandable Codex accounts flyout inside the tray menu card (option A): collapsed summary, per-account usage bars, and switch actions, using the shared bridge. Adds the flyout styles and MenuCard integration. --- .../src/components/CodexAccountsMenu.test.tsx | 136 ++++++++++++++++ .../src/components/CodexAccountsMenu.tsx | 148 ++++++++++++++++++ .../desktop-tauri/src/components/MenuCard.tsx | 7 +- apps/desktop-tauri/src/styles.css | 137 ++++++++++++++++ 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx create mode 100644 apps/desktop-tauri/src/components/CodexAccountsMenu.tsx diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx new file mode 100644 index 0000000000..9b66d809d3 --- /dev/null +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx @@ -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 { + 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( + + + , + ); +} + +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); + }); +}); \ No newline at end of file diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx new file mode 100644 index 0000000000..a79d8d6fa4 --- /dev/null +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx @@ -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([]); + const [snapshots, setSnapshots] = useState< + Record + >({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+ + {t("CodexAccountsTitle")} + {accounts.length} + + {error && ( +
+ {error} +
+ )} +
    + {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 ( +
  • +
    +
    + + {shown} + {isAmbient && ( + + {t("CodexAccountsSourceAmbient")} + + )} + + {pct !== null && ( + + + + )} +
    + +
    +
  • + ); + })} +
+
+ ); +} + +function shrink(id: string): string { + return id.length <= 12 ? id : `${id.slice(0, 8)}…`; +} \ No newline at end of file diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 65e1a17c7f..dfd5180154 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -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 }) { @@ -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); @@ -240,6 +241,10 @@ export default function MenuCard({ onLayoutChange={onLayoutChange} /> )} + + {provider.providerId === "codex" && ( + + )} ); } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 29c7f1c978..04a744c8ec 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3873,6 +3873,143 @@ html:has(.menu-surface--tray) { padding-bottom: var(--menu-card-section-y); } +/* ── Multi-account Codex lanes (ADR 0003, option A) ─────────────────── */ + +.codex-menu-accounts { + margin-top: 4px; + border-top: 1px solid var(--divider, var(--provider-row-divider, rgba(128, 128, 128, 0.18))); + padding-top: 4px; +} + +.codex-menu-accounts__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--provider-row-text-secondary); + user-select: none; +} + +.codex-menu-accounts__summary::-webkit-details-marker { + display: none; +} + +.codex-menu-accounts__summary::before { + content: "▸"; + display: inline-block; + width: 0.8em; + color: var(--provider-row-text-secondary); + transition: transform 0.12s; +} + +.codex-menu-accounts[open] > .codex-menu-accounts__summary::before { + transform: rotate(90deg); +} + +.codex-menu-accounts__count { + margin-left: auto; + font-size: 0.68rem; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: var(--provider-row-text-secondary); +} + +.codex-menu-accounts__error { + margin-top: 6px; + font-size: 0.72rem; + color: var(--provider-status-error); +} + +.codex-menu-accounts__list { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.codex-menu-accounts__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.codex-menu-accounts__row--active .codex-menu-accounts__email { + color: var(--text-primary); + font-weight: 600; +} + +.codex-menu-accounts__meta { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1 1 auto; +} + +.codex-menu-accounts__email { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.74rem; + color: var(--provider-row-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.codex-menu-accounts__badge { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--provider-status-ok, #4ade80); +} + +.codex-menu-accounts__bar { + display: block; + height: 4px; + border-radius: 2px; + background: var(--provider-row-divider, rgba(128, 128, 128, 0.18)); + overflow: hidden; +} + +.codex-menu-accounts__bar-fill { + display: block; + height: 100%; + border-radius: 2px; + background: var(--accent, currentColor); +} + +.codex-menu-accounts__switch { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 5px; + color: var(--text-primary); + background: transparent; + border: 1px solid var(--provider-row-divider, rgba(128, 128, 128, 0.3)); + cursor: pointer; +} + +.codex-menu-accounts__switch:hover:not(:disabled) { + border-color: var(--accent, currentColor); +} + +.codex-menu-accounts__switch:disabled { + opacity: 0.4; + cursor: default; +} + /* Header VStack(spacing: 4) with two HStacks */ .menu-card__header { display: flex;