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
126 changes: 113 additions & 13 deletions apps/desktop-tauri/src-tauri/src/tray_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -205,6 +201,38 @@ fn codex_accounts_menu(
)
}

fn codex_account_menu_label(
account: &CodexAccount,
hide_personal_info: bool,
ordinal: Option<usize>,
) -> String {
if hide_personal_info {
return format!("Account {}", ordinal.unwrap_or(1));
}
account.display_name()
}

fn codex_account_ordinals(accounts: &[CodexAccount]) -> HashMap<Uuid, usize> {
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,
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 38 additions & 2 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): CodexAccount {
return {
Expand Down Expand Up @@ -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: {},
Expand All @@ -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")],
Expand All @@ -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");
});
});

99 changes: 75 additions & 24 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,63 @@ 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,
getCodexAccountsState,
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<string, number> {
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<string, number> = {};
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
Expand Down Expand Up @@ -99,6 +148,7 @@ export default function CodexAccountsMenu({
accounts,
displayNames,
);
const accountOrdinals = buildCodexAccountOrdinals(accounts);

return (
<details className="codex-menu-accounts" onToggle={onLayoutChange}>
Expand All @@ -112,18 +162,26 @@ export default function CodexAccountsMenu({
</div>
)}
<ul className="codex-menu-accounts__list">
{accounts.map((account) => (
<CodexAccountRow
key={account.id}
account={account}
snapshot={snapshots[account.id]}
displayName={accountDisplayNames[account.id]}
hideEmail={hideEmail}
resetTimeRelative={resetTimeRelative}
busy={busy}
onSwitch={handleSwitch}
/>
))}
{accounts.map((account, index) => {
const privateLabel = buildPrivateCodexAccountLabel(
account,
accountDisplayNames[account.id] ?? "",
accountOrdinals[account.id] ?? index + 1,
hideEmail,
);
return (
<CodexAccountRow
key={account.id}
account={account}
snapshot={snapshots[account.id]}
displayName={privateLabel.label}
tooltip={privateLabel.tooltip}
resetTimeRelative={resetTimeRelative}
busy={busy}
onSwitch={handleSwitch}
/>
);
})}
</ul>
</details>
);
Expand All @@ -133,15 +191,15 @@ function CodexAccountRow({
account,
snapshot,
displayName,
hideEmail,
tooltip,
resetTimeRelative,
busy,
onSwitch,
}: {
account: CodexAccount;
snapshot: CodexAccountUsageSnapshot | undefined;
displayName: string;
hideEmail: boolean;
tooltip: string;
resetTimeRelative: boolean;
busy: boolean;
onSwitch: (id: string) => Promise<void>;
Expand All @@ -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 (
Expand All @@ -179,8 +230,8 @@ function CodexAccountRow({
className={`codex-menu-accounts__row${isAmbient ? " codex-menu-accounts__row--active" : ""}`}
>
<div className="codex-menu-accounts__meta">
<span className="codex-menu-accounts__email" title={shown}>
{shown}
<span className="codex-menu-accounts__email" title={tooltip}>
{displayName}
{isAmbient && (
<span className="codex-menu-accounts__badge">
{t("CodexAccountsSourceAmbient")}
Expand Down