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
42 changes: 27 additions & 15 deletions apps/desktop-tauri/src-tauri/src/tray_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,11 @@ fn codex_account_menu_label(
hide_personal_info: bool,
ordinal: usize,
) -> String {
if hide_personal_info {
return format!("{} {ordinal}", locale::get_text(lang, LocaleKey::Account));
}
account.display_name()
account.privacy_safe_display_name(
hide_personal_info,
ordinal,
&locale::get_text(lang, LocaleKey::Account),
)
}

fn claude_accounts_menu(
Expand Down Expand Up @@ -444,6 +445,7 @@ mod tests {
let empty = codex_accounts_menu(&[], None, Language::English, false);
assert!(menu_contains(&empty.children, "add_codex_account"));
let mut email_account = second;
email_account.source = CodexAccountSource::Ambient;
email_account.nickname = None;
email_account.email_hint = Some("private@example.com".into());
let private = codex_accounts_menu(&[email_account.clone()], None, Language::English, true);
Expand All @@ -470,16 +472,18 @@ mod tests {
None,
)
};
let with_nickname = make(
let mut with_nickname = make(
"00000000-0000-0000-0000-000000000002",
Some("Work"),
"user@example.com",
);
let without_nickname = make(
let mut without_nickname = make(
"00000000-0000-0000-0000-000000000001",
None,
"personal@example.com",
);
with_nickname.source = CodexAccountSource::ManagedByApp;
without_nickname.source = CodexAccountSource::Ambient;

let accounts = [with_nickname.clone(), without_nickname.clone()];
let ordinals = ordinals_by_id(&accounts);
Expand All @@ -492,7 +496,7 @@ mod tests {
true,
ordinals[&with_nickname.id],
),
"Account 2"
"user@example.com — Work"
);
assert_eq!(
codex_account_menu_label(
Expand All @@ -504,14 +508,22 @@ mod tests {
"Account 1"
);

let hidden = codex_accounts_menu(&accounts, None, Language::English, true);
assert_eq!(hidden.children[0].label, "Account 2");
let hidden =
codex_accounts_menu(&accounts, Some(&without_nickname), Language::English, true);
assert_eq!(hidden.children[0].label, "user@example.com — Work");
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"));
}
assert_eq!(hidden.children[0].checked, Some(false));
assert!(!hidden.children[0].disabled);
assert_eq!(hidden.children[1].checked, Some(true));
assert!(hidden.children[1].disabled);
assert_eq!(
hidden.children[1].id.as_deref(),
Some(format!("switch_codex_account:{}", without_nickname.id).as_str())
);
assert!(hidden.children[0].label.contains("Work"));
assert!(!hidden.children[1].label.contains('@'));
assert!(!hidden.children[1].label.contains("example.com"));
assert!(!hidden.children[1].label.contains("personal"));

let reversed = codex_accounts_menu(
&[without_nickname.clone(), with_nickname.clone()],
Expand All @@ -520,7 +532,7 @@ mod tests {
true,
);
assert_eq!(reversed.children[0].label, "Account 1");
assert_eq!(reversed.children[1].label, "Account 2");
assert_eq!(reversed.children[1].label, "user@example.com — Work");

let visible = codex_accounts_menu(&[with_nickname], None, Language::English, false);
assert_eq!(visible.children[0].label, "user@example.com — Work");
Expand Down
31 changes: 21 additions & 10 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe("CodexAccountsMenu", () => {
expect(tauriMocks.codexAccountSwitch).toHaveBeenCalledWith("2");
expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1);
});
it("uses opaque ordinal labels and matching tooltips while hideEmail is on", async () => {
it("uses an opaque ordinal only for the ambient account while hideEmail is on", async () => {
const { container: hidden } = renderMenu(true, {
accounts: [account("1", { source: "ambient" }), account("2")],
accountOrdinals: { "1": 1, "2": 2 },
Expand All @@ -213,13 +213,23 @@ describe("CodexAccountsMenu", () => {
hidden.querySelectorAll(".codex-menu-accounts__email").length,
).toBe(2);
});
const hiddenEmail = hidden.querySelectorAll(
const ambientLabel = hidden.querySelectorAll(
".codex-menu-accounts__email",
)[0] as HTMLElement;
expect(ambientLabel.getAttribute("title")).toBe("Account 1");
expect(ambientLabel.firstChild?.textContent).toBe("Account 1");
expect(ambientLabel.firstChild?.textContent).not.toContain("@");
expect(ambientLabel.firstChild?.textContent).not.toContain("example.com");

const managedEmail = hidden.querySelectorAll(
".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");
expect(managedEmail.textContent).toBe("user-2@example.com");
const switches = hidden.querySelectorAll(
".codex-menu-accounts__switch",
) as NodeListOf<HTMLButtonElement>;
expect(switches[0].disabled).toBe(true);
expect(switches[1].disabled).toBe(false);

const { container: visible } = renderMenu(false, {
accounts: [account("1", { source: "ambient" }), account("2")],
Expand All @@ -241,6 +251,7 @@ describe("CodexAccountsMenu", () => {
const first = account("uuid-b", {
emailHint: "alice@example.com",
nickname: "team@example.com",
source: "ambient",
});
const second = account("uuid-a", {
emailHint: "bob@example.com",
Expand All @@ -254,10 +265,10 @@ describe("CodexAccountsMenu", () => {
});
await screen.findByText("Account 2");
const labels = container.querySelectorAll(".codex-menu-accounts__email");
expect(labels[0].textContent).toBe("Account 2");
expect(labels[1].textContent).toBe("Account 1");
expect(labels[0].textContent).not.toContain("@");
expect(labels[1].textContent).not.toContain("example.com");
expect(labels[0].firstChild?.textContent).toBe("Account 2");
expect(labels[1].textContent).toBe("bob@example.com — Private workspace");
expect(labels[0].firstChild?.textContent).not.toContain("@");
expect(labels[1].textContent).toContain("Private workspace");
});
});

45 changes: 8 additions & 37 deletions apps/desktop-tauri/src/components/CodexAccountsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,13 @@ import type {
} from "../types/bridge";
import { useLocale } from "../hooks/useLocale";
import { useFormattedResetTime } from "../hooks/useFormattedResetTime";
import { buildCodexAccountDisplayNames } from "./codexAccountDisplay";
import { buildCodexAccountSurfaceLabels } from "./codexAccountDisplay";
import {
codexAccountSwitch,
getCodexAccountsState,
refreshProviders,
} from "../lib/tauri";

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.
*/
function buildPrivateCodexAccountLabel(
account: CodexAccount,
displayName: string,
ordinal: number,
hidePersonalInfo: boolean,
accountWord: string,
): PrivateCodexAccountLabel {
if (hidePersonalInfo) {
const label = `${accountWord} ${ordinal}`;
return { label, tooltip: label };
}

const label = displayName || account.nickname || "Workspace";
return { label, tooltip: label };
}

/**
* 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 @@ -122,9 +96,12 @@ export default function CodexAccountsMenu({
return null;
}

const accountDisplayNames = buildCodexAccountDisplayNames(
const accountDisplayNames = buildCodexAccountSurfaceLabels(
accounts,
displayNames,
accountOrdinals,
hideEmail,
t("Account"),
);

return (
Expand All @@ -140,20 +117,14 @@ export default function CodexAccountsMenu({
)}
<ul className="codex-menu-accounts__list">
{accounts.map((account) => {
const privateLabel = buildPrivateCodexAccountLabel(
account,
accountDisplayNames[account.id] ?? "",
accountOrdinals[account.id],
hideEmail,
t("Account"),
);
const label = accountDisplayNames[account.id];
return (
<CodexAccountRow
key={account.id}
account={account}
snapshot={snapshots[account.id]}
displayName={privateLabel.label}
tooltip={privateLabel.tooltip}
displayName={label}
tooltip={label}
resetTimeRelative={resetTimeRelative}
busy={busy}
onSwitch={handleSwitch}
Expand Down
44 changes: 43 additions & 1 deletion apps/desktop-tauri/src/components/codexAccountDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { CodexAccount } from "../types/bridge";
import { buildCodexAccountDisplayNames } from "./codexAccountDisplay";
import {
buildCodexAccountDisplayNames,
buildCodexAccountSurfaceLabels,
} from "./codexAccountDisplay";

function account(id: string, providerAccountId: string): CodexAccount {
return {
Expand Down Expand Up @@ -82,4 +85,43 @@ describe("Codex account display labels", () => {
expect(label).not.toContain("secret-subject");
expect(label).not.toContain("C:/private");
});

it("redacts only the ambient account while preserving managed labels", () => {
const system = account("11111111-1111-1111-1111-111111111111", "system");
const managed = account("22222222-2222-2222-2222-222222222222", "managed");
const accounts = [
{ ...system, source: "ambient" as const, nickname: "Private System Name" },
{ ...managed, nickname: "Work" },
];
const hidden = buildCodexAccountSurfaceLabels(
accounts,
{},
{ [system.id]: 1, [managed.id]: 2 },
true,
"Account",
);
expect(hidden[system.id]).toBe("Account 1");
expect(hidden[system.id]).not.toContain("@");
expect(hidden[system.id]).not.toContain("Private System Name");
expect(hidden[managed.id]).toBe("same@example.com — Work");

const reordered = buildCodexAccountSurfaceLabels(
[accounts[1], accounts[0]],
{},
{ [system.id]: 1, [managed.id]: 2 },
true,
"Account",
);
expect(reordered[system.id]).toBe("Account 1");

const visible = buildCodexAccountSurfaceLabels(
accounts,
{},
{ [system.id]: 1, [managed.id]: 2 },
false,
"Account",
);
expect(visible[system.id]).toBe("same@example.com — Private System Name");
expect(visible[managed.id]).toBe("same@example.com — Work");
});
});
27 changes: 27 additions & 0 deletions apps/desktop-tauri/src/components/codexAccountDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ export function buildCodexAccountDisplayNames(
return result;
}

/**
* Project the labels rendered by account rows in the tray and Settings.
* The redaction rule mirrors `CodexAccount::privacy_safe_display_name` in the
* Rust model: privacy mode redacts only the ambient/System account; managed
* account labels keep the canonical display-name projection. Client-side
* projection keeps the relabel reactive when the setting toggles without a
* refetch.
*/
export function buildCodexAccountSurfaceLabels(
accounts: readonly CodexAccount[],
canonical: Readonly<Record<string, string>>,
accountOrdinals: Readonly<Record<string, number>>,
hidePersonalInfo: boolean,
accountWord: string,
): Record<string, string> {
const displayNames = buildCodexAccountDisplayNames(accounts, canonical);

return Object.fromEntries(
accounts.map((account) => {
if (hidePersonalInfo && account.source === "ambient") {
return [account.id, `${accountWord.trim()} ${accountOrdinals[account.id]}`];
}
return [account.id, displayNames[account.id]];
}),
);
}

export function codexAccountBaseName(account: CodexAccount): string {
const nickname = account.nickname?.trim();
const email = account.emailHint?.trim().toLowerCase();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface Props {
/** Per-provider accent color overrides (CLI name → hex color). */
providerAccentColors: SettingsSnapshot["providerAccentColors"];
wayfinderGatewayUrl: string;
hidePersonalInfo: boolean;
settingsDisabled: boolean;
onSettingsChange: (patch: SettingsUpdate) => void;
}
Expand All @@ -74,6 +75,7 @@ export function ProviderDetailPane({
providerMetrics,
providerAccentColors,
wayfinderGatewayUrl,
hidePersonalInfo,
settingsDisabled,
onSettingsChange,
}: Props) {
Expand Down Expand Up @@ -270,7 +272,9 @@ export function ProviderDetailPane({
<div className="provider-detail">
<IdentitySection provider={detail} subtitle={subtitle} t={t} />

{detail.id === "codex" && <CodexAccountsSection t={t} />}
{detail.id === "codex" && (
<CodexAccountsSection t={t} hidePersonalInfo={hidePersonalInfo} />
)}
{detail.id === "claude" && <ClaudeAccountsSection t={t} language={language} />}
{detail.id === "grok" && <GrokAccountsSection t={t} />}

Expand Down
Loading