diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index 44b6355303..a3be33b577 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -3,10 +3,13 @@ //! Keep provider/account workflows out of the generic tray shell so adding a //! new account action does not grow `tray_bridge.rs` into another controller. +use std::collections::HashMap; + use codexbar::codex_accounts::CodexAccount; use codexbar::locale::{self, LocaleKey}; use codexbar::settings::{Language, Settings}; use tauri::AppHandle; +use uuid::Uuid; use crate::tray_menu::TrayMenuEntry; @@ -162,25 +165,18 @@ fn codex_accounts_menu( hide_personal_info: bool, ) -> TrayMenuEntry { let text = |key| locale::get_text(lang, key); + let ordinals = codex_account_ordinals(accounts); let mut children: Vec<_> = accounts .iter() .map(|account| { let is_active = active.is_some_and(|current| current.matches(account)); let mut entry = TrayMenuEntry::check_item( format!("switch_codex_account:{}", account.id), - if hide_personal_info - && account - .nickname - .as_deref() - .is_none_or(|n| n.trim().is_empty()) - { - codexbar::core::PersonalInfoRedactor::partial_redact_email( - account.email_hint.as_deref(), - true, - ) - } else { - account.display_name() - }, + codex_account_menu_label( + account, + hide_personal_info, + ordinals.get(&account.id).copied(), + ), is_active, ); entry.disabled = is_active; @@ -205,6 +201,38 @@ fn codex_accounts_menu( ) } +fn codex_account_menu_label( + account: &CodexAccount, + hide_personal_info: bool, + ordinal: Option, +) -> String { + if hide_personal_info { + return format!("Account {}", ordinal.unwrap_or(1)); + } + account.display_name() +} + +fn codex_account_ordinals(accounts: &[CodexAccount]) -> HashMap { + let mut ordered: Vec<(String, usize, Uuid)> = accounts + .iter() + .enumerate() + .map(|(index, account)| { + ( + account.id.to_string().to_ascii_lowercase(), + index, + account.id, + ) + }) + .collect(); + ordered.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + + ordered + .into_iter() + .enumerate() + .map(|(index, (_, _, id))| (id, index + 1)) + .collect() +} + fn claude_accounts_menu( accounts: &[codexbar::providers::claude::accounts::ClaudeAccount], lang: Language, @@ -338,6 +366,78 @@ mod tests { assert_eq!(visible.children[0].label, "private@example.com"); } + #[test] + fn hidden_codex_tray_labels_are_opaque_and_stable() { + use codexbar::codex_accounts::{CodexAccountSource, utc_now}; + + let make = |id: &str, nickname: Option<&str>, email: &str| { + CodexAccount::new( + Uuid::parse_str(id).unwrap(), + nickname.map(str::to_string), + Some(email.to_string()), + None, + None, + std::path::PathBuf::from("C:/private-home"), + CodexAccountSource::ManagedByApp, + utc_now(), + utc_now(), + None, + ) + }; + let with_nickname = make( + "00000000-0000-0000-0000-000000000002", + Some("Work"), + "user@example.com", + ); + let without_nickname = make( + "00000000-0000-0000-0000-000000000001", + None, + "personal@example.com", + ); + + let accounts = [with_nickname.clone(), without_nickname.clone()]; + let ordinals = codex_account_ordinals(&accounts); + assert_eq!(ordinals[&without_nickname.id], 1); + assert_eq!(ordinals[&with_nickname.id], 2); + assert_eq!( + codex_account_menu_label( + &with_nickname, + true, + ordinals.get(&with_nickname.id).copied(), + ), + "Account 2" + ); + assert_eq!( + codex_account_menu_label( + &without_nickname, + true, + ordinals.get(&without_nickname.id).copied(), + ), + "Account 1" + ); + + let hidden = codex_accounts_menu(&accounts, None, Language::English, true); + assert_eq!(hidden.children[0].label, "Account 2"); + assert_eq!(hidden.children[1].label, "Account 1"); + for entry in hidden.children.iter().take(2) { + assert!(!entry.label.contains('@')); + assert!(!entry.label.contains("example.com")); + assert!(!entry.label.contains("Work")); + } + + let reversed = codex_accounts_menu( + &[without_nickname.clone(), with_nickname.clone()], + None, + Language::English, + true, + ); + assert_eq!(reversed.children[0].label, "Account 1"); + assert_eq!(reversed.children[1].label, "Account 2"); + + let visible = codex_accounts_menu(&[with_nickname], None, Language::English, false); + assert_eq!(visible.children[0].label, "user@example.com — Work"); + } + #[test] fn claude_menu_checks_current_account_and_routes_saved_accounts() { use codexbar::providers::claude::accounts::ClaudeAccount; diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx index 6c405bb23e..99e5b4c4a6 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx @@ -22,7 +22,10 @@ const eventMocks = vi.hoisted(() => ({ vi.mock("../lib/tauri", () => tauriMocks); vi.mock("@tauri-apps/api/event", () => eventMocks); -import CodexAccountsMenu from "./CodexAccountsMenu"; +import CodexAccountsMenu, { + buildCodexAccountOrdinals, + buildPrivateCodexAccountLabel, +} from "./CodexAccountsMenu"; function account(id: string, extra: Partial = {}): CodexAccount { return { @@ -196,7 +199,7 @@ describe("CodexAccountsMenu", () => { expect(tauriMocks.codexAccountSwitch).toHaveBeenCalledWith("2"); expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); }); - it("keeps the email tooltip masked while hideEmail is on and raw when off", async () => { + it("uses opaque ordinal labels and matching tooltips while hideEmail is on", async () => { const { container: hidden } = renderMenu(true, { accounts: [account("1", { source: "ambient" }), account("2")], snapshots: {}, @@ -210,6 +213,9 @@ describe("CodexAccountsMenu", () => { ".codex-menu-accounts__email", )[1] as HTMLElement; expect(hiddenEmail.getAttribute("title")).toBe(hiddenEmail.textContent); + expect(hiddenEmail.textContent).toBe("Account 2"); + expect(hiddenEmail.textContent).not.toContain("@"); + expect(hiddenEmail.textContent).not.toContain("example.com"); const { container: visible } = renderMenu(false, { accounts: [account("1", { source: "ambient" }), account("2")], @@ -225,5 +231,35 @@ describe("CodexAccountsMenu", () => { )[1] as HTMLElement; expect(rawEmail.getAttribute("title")).toBe("user-2@example.com"); }); + + it("redacts email-like account metadata and keeps ordinals stable across refresh order", () => { + const first = account("uuid-b", { + emailHint: "alice@example.com", + nickname: "team@example.com", + }); + const second = account("uuid-a", { + emailHint: "bob@example.com", + nickname: "Private workspace", + }); + + const forward = buildCodexAccountOrdinals([first, second]); + const reversed = buildCodexAccountOrdinals([second, first]); + expect(forward).toEqual(reversed); + expect(forward[first.id]).toBe(2); + expect(forward[second.id]).toBe(1); + + const privateLabel = buildPrivateCodexAccountLabel( + first, + "alice@example.com — team@example.com", + forward[first.id], + true, + ); + expect(privateLabel).toEqual({ + label: "Account 2", + tooltip: "Account 2", + }); + expect(privateLabel.label).not.toContain("@"); + expect(privateLabel.label).not.toContain("example.com"); + }); }); diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx index fdfc1cdbec..1f31771f99 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx @@ -7,7 +7,6 @@ import type { } from "../types/bridge"; import { useLocale } from "../hooks/useLocale"; import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; -import { maskEmail } from "./MenuCard"; import { buildCodexAccountDisplayNames } from "./codexAccountDisplay"; import { codexAccountSwitch, @@ -15,6 +14,56 @@ import { refreshProviders, } from "../lib/tauri"; +export interface PrivateCodexAccountLabel { + label: string; + tooltip: string; +} + +/** + * Project a tray account label while keeping the privacy setting scoped to + * this switcher surface. The shared display-name builder remains unchanged so + * settings and other account-facing surfaces keep their existing behavior. + */ +export function buildPrivateCodexAccountLabel( + account: CodexAccount, + displayName: string, + ordinal: number, + hidePersonalInfo: boolean, +): PrivateCodexAccountLabel { + if (hidePersonalInfo) { + const label = `Account ${ordinal}`; + return { label, tooltip: label }; + } + + const label = displayName || account.nickname || "Workspace"; + return { label, tooltip: label }; +} + +/** + * Assign ordinals from the opaque stable account id rather than the current + * discovery order. This keeps hidden labels stable when the backend refreshes + * or reorders account rows. + */ +export function buildCodexAccountOrdinals( + accounts: readonly CodexAccount[], +): Record { + const ordered = accounts + .map((account, index) => ({ account, index })) + .sort((left, right) => { + const leftId = left.account.id.trim().toLowerCase(); + const rightId = right.account.id.trim().toLowerCase(); + if (leftId < rightId) return -1; + if (leftId > rightId) return 1; + return left.index - right.index; + }); + + const ordinals: Record = {}; + ordered.forEach(({ account }, index) => { + ordinals[account.id] = index + 1; + }); + return ordinals; +} + /** * 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 @@ -99,6 +148,7 @@ export default function CodexAccountsMenu({ accounts, displayNames, ); + const accountOrdinals = buildCodexAccountOrdinals(accounts); return (
@@ -112,18 +162,26 @@ export default function CodexAccountsMenu({ )}
    - {accounts.map((account) => ( - - ))} + {accounts.map((account, index) => { + const privateLabel = buildPrivateCodexAccountLabel( + account, + accountDisplayNames[account.id] ?? "", + accountOrdinals[account.id] ?? index + 1, + hideEmail, + ); + return ( + + ); + })}
); @@ -133,7 +191,7 @@ function CodexAccountRow({ account, snapshot, displayName, - hideEmail, + tooltip, resetTimeRelative, busy, onSwitch, @@ -141,7 +199,7 @@ function CodexAccountRow({ account: CodexAccount; snapshot: CodexAccountUsageSnapshot | undefined; displayName: string; - hideEmail: boolean; + tooltip: string; resetTimeRelative: boolean; busy: boolean; onSwitch: (id: string) => Promise; @@ -164,13 +222,6 @@ function CodexAccountRow({ : `${t("MetricResetsIn")} ${resetText}` : null; const windowLabel = formatWindowLabel(usageWindow?.limitWindowSeconds); - // Only mask labels that actually contain an email. Generic/nickname labels - // have no personal data to hide, and masking them would erase their opaque - // workspace suffix and make distinct accounts look identical. - const shown = - hideEmail && displayName.includes("@") - ? maskEmail(displayName) - : displayName; const isAmbient = account.source === "ambient"; return ( @@ -179,8 +230,8 @@ function CodexAccountRow({ className={`codex-menu-accounts__row${isAmbient ? " codex-menu-accounts__row--active" : ""}`} >
- - {shown} + + {displayName} {isAmbient && ( {t("CodexAccountsSourceAmbient")}