From 35e6a4e4cb069087bbc64aec43c9ccb9e24498be Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:32:03 +0700 Subject: [PATCH 01/13] Add card anatomy strings, settings deep link and drift tests --- apps/desktop-tauri/src-tauri/src/surface.rs | 41 +++ apps/desktop-tauri/src/App.tsx | 9 +- .../src/hooks/useTrayPanelController.ts | 10 +- apps/desktop-tauri/src/i18n/keys.ts | 11 + .../src/lib/accountMenuRow.test.ts | 72 +++++ apps/desktop-tauri/src/lib/accountMenuRow.ts | 30 ++ apps/desktop-tauri/src/lib/cardCost.test.ts | 155 +++++++++ apps/desktop-tauri/src/lib/cardCost.ts | 118 +++++++ .../src/lib/inlineDashboard.test.ts | 248 ++++++++++++++ apps/desktop-tauri/src/lib/inlineDashboard.ts | 306 ++++++++++++++++++ apps/desktop-tauri/src/surfaces/Settings.tsx | 38 ++- .../src/surfaces/TrayPanel.test.tsx | 11 +- .../settings/providers/ProviderDetailPane.tsx | 9 +- .../surfaces/settings/settingsTabs.test.ts | 48 ++- .../src/surfaces/settings/settingsTabs.ts | 25 ++ .../settings/tabs/ProvidersTab.test.tsx | 89 ++++- .../surfaces/settings/tabs/ProvidersTab.tsx | 20 +- rust/src/locale.rs | 11 + rust/src/locale/en-US.ftl | 11 + rust/src/locale/es-MX.ftl | 11 + rust/src/locale/ja-JP.ftl | 11 + rust/src/locale/ko-KR.ftl | 11 + rust/src/locale/pt-BR.ftl | 11 + rust/src/locale/ru-RU.ftl | 11 + rust/src/locale/tr-TR.ftl | 11 + rust/src/locale/uk-UA.ftl | 11 + rust/src/locale/zh-CN.ftl | 11 + rust/src/locale/zh-TW.ftl | 11 + rust/src/settings.rs | 7 +- rust/src/settings/preferences_document.rs | 5 +- .../settings/preferences_document/tests.rs | 21 ++ 31 files changed, 1370 insertions(+), 24 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/accountMenuRow.test.ts create mode 100644 apps/desktop-tauri/src/lib/accountMenuRow.ts create mode 100644 apps/desktop-tauri/src/lib/cardCost.test.ts create mode 100644 apps/desktop-tauri/src/lib/cardCost.ts create mode 100644 apps/desktop-tauri/src/lib/inlineDashboard.test.ts create mode 100644 apps/desktop-tauri/src/lib/inlineDashboard.ts diff --git a/apps/desktop-tauri/src-tauri/src/surface.rs b/apps/desktop-tauri/src-tauri/src/surface.rs index 9395af04ba..f7cf5318b7 100644 --- a/apps/desktop-tauri/src-tauri/src/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/surface.rs @@ -265,6 +265,47 @@ mod tests { assert_eq!(SurfaceMode::parse("bogus"), None); } + /// Reads `const NAME = ;` from a TypeScript source. + fn ts_integer_const(source: &str, name: &str) -> u32 { + let prefix = format!("const {name} = "); + let line = source + .lines() + .find_map(|line| { + line.trim_start() + .strip_prefix("export ") + .unwrap_or(line.trim_start()) + .strip_prefix(&prefix) + }) + .unwrap_or_else(|| panic!("{name} not found")); + line.trim_end_matches(';') + .trim() + .parse() + .unwrap_or_else(|_| panic!("{name} is not an integer: {line}")) + } + + #[test] + fn tray_panel_width_matches_the_frontend_layout_base_width() { + let layout = include_str!("../../src/hooks/useTrayPanelLayout.ts"); + let width = SurfaceMode::TrayPanel.window_properties().width; + assert_eq!( + f64::from(ts_integer_const(layout, "TRAY_BASE_WIDTH")), + width + ); + } + + #[test] + fn tray_scale_bounds_match_the_frontend_scale_bounds() { + let scale = include_str!("../../src/lib/trayScale.ts"); + assert_eq!( + ts_integer_const(scale, "TRAY_SCALE_MIN"), + u32::from(codexbar::settings::TRAY_SCALE_PERCENT_MIN) + ); + assert_eq!( + ts_integer_const(scale, "TRAY_SCALE_MAX"), + u32::from(codexbar::settings::TRAY_SCALE_PERCENT_MAX) + ); + } + #[test] fn tray_panel_properties() { let props = SurfaceMode::TrayPanel.window_properties(); diff --git a/apps/desktop-tauri/src/App.tsx b/apps/desktop-tauri/src/App.tsx index 610020c678..fc9c8c3e99 100644 --- a/apps/desktop-tauri/src/App.tsx +++ b/apps/desktop-tauri/src/App.tsx @@ -220,13 +220,14 @@ function SettingsLayout({ state }: { state: BootstrapState }) { } function DetachedSettingsApp({ state }: { state: BootstrapState }) { - const [tab, setTab] = useState(initialSettingsTab); + const [request, setRequest] = useState(() => ({ tab: initialSettingsTab(), id: 0 })); useEffect(() => { // Listen for tab-change events from Rust (when the window is re-focused - // with a different tab request). + // with a different tab request). Each event gets a new id so repeating + // the same `providers:` deep link selects that provider again. const unlisten = listen("settings-change-tab", (event) => { - setTab(event.payload); + setRequest((current) => ({ tab: event.payload, id: current.id + 1 })); }); return () => { unlisten.then((fn) => fn()); @@ -236,7 +237,7 @@ function DetachedSettingsApp({ state }: { state: BootstrapState }) { return ( }>
- +
); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 1a69719eac..477effcb02 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -22,7 +22,7 @@ import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; import type { MenuFooterRow } from "../components/MenuSurface"; -import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; +import { accountMenuAction } from "../lib/accountMenuRow"; import { orderProviderSnapshots } from "../lib/providerOrder"; import { clampTrayScalePercent } from "../lib/trayScale"; import { @@ -211,11 +211,13 @@ export function useTrayPanelController(state: BootstrapState) { }, []); const actionRows: MenuFooterRow[] = []; - if (actionProvider !== null && hasSuccessfulClaudeCliQuota(actionProvider)) { + const accountAction = accountMenuAction(actionProvider); + if (accountAction !== null) { + const { settingsTab } = accountAction; actionRows.push({ id: "switchAccount", - label: t("TrayMenuSwitchAccount"), - onClick: () => void openSettingsWindow("providers").catch(() => {}), + label: t(accountAction.labelKey), + onClick: () => void openSettingsWindow(settingsTab).catch(() => {}), }); } if (links?.dashboard) { diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index bca1f5588c..c002e35809 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -807,6 +807,17 @@ export const ALL_LOCALE_KEYS = [ "PanelLatestTokens", "PanelThirtyDayCostHistogram", "PanelTopModelPrefix", + "AddAccountEllipsis", + "PanelLatest", + "PanelEstimatedValue", + "PanelCurrentWindow", + "PanelCurrentWindowTokens", + "PanelPreviousWindow", + "PanelWindowsAgo", + "PanelRecentWindows", + "QuotaUsageTitle", + "CostPeriodThisMonth", + "PanelPartialEstimate", "PanelEstimatedFromLocalLogs", "PanelEstimatedFromLocalLogsClaude", "PanelEstimatedFromLocalLogsMuse", diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.test.ts b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts new file mode 100644 index 0000000000..6d3bfcf6dc --- /dev/null +++ b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../types/bridge"; +import { accountMenuAction } from "./accountMenuRow"; + +const window: RateWindowSnapshot = { + usedPercent: 25, + remainingPercent: 75, + windowMinutes: 300, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + isInformational: false, +}; + +function snapshot(overrides: Partial): ProviderUsageSnapshot { + return { + providerId: "codex", + displayName: "Codex", + primary: window, + selectedMetric: window, + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "auto", + updatedAt: "2026-10-11T00:00:00Z", + error: null, + errorState: "ready", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + ...overrides, + }; +} + +describe("accountMenuAction", () => { + it.each(["codex", "claude", "grok"])("offers Switch Account... for a signed-in %s", (providerId) => { + expect(accountMenuAction(snapshot({ providerId, accountEmail: "a@example.com" }))).toEqual({ + labelKey: "TrayMenuSwitchAccount", + settingsTab: `providers:${providerId}`, + }); + }); + + it.each(["codex", "claude", "grok"])("offers Add Account... when %s has no account", (providerId) => { + expect(accountMenuAction(snapshot({ providerId }))).toEqual({ + labelKey: "AddAccountEllipsis", + settingsTab: `providers:${providerId}`, + }); + }); + + it("treats a live Claude CLI quota as a signed-in account without an email", () => { + expect( + accountMenuAction( + snapshot({ providerId: "claude", sourceLabel: "Claude CLI", hasSuccessfulClaudeCliQuota: true }), + )?.labelKey, + ).toBe("TrayMenuSwitchAccount"); + }); + + it("ignores a blank email", () => { + expect(accountMenuAction(snapshot({ accountEmail: " " }))?.labelKey).toBe("AddAccountEllipsis"); + }); + + it("offers nothing for providers without an account manager", () => { + expect(accountMenuAction(snapshot({ providerId: "cursor", accountEmail: "a@example.com" }))).toBeNull(); + expect(accountMenuAction(null)).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.ts b/apps/desktop-tauri/src/lib/accountMenuRow.ts new file mode 100644 index 0000000000..899ade3cfa --- /dev/null +++ b/apps/desktop-tauri/src/lib/accountMenuRow.ts @@ -0,0 +1,30 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderUsageSnapshot } from "../types/bridge"; +import { providersTabRequest } from "../surfaces/settings/settingsTabs"; +import { hasSuccessfulClaudeCliQuota } from "./claudeAccountActions"; + +/** Providers whose account manager lives in Settings > Providers > that provider. */ +const ACCOUNT_MANAGER_PROVIDERS = new Set(["codex", "claude", "grok"]); + +export interface AccountMenuAction { + labelKey: Extract; + /** `open_settings_window` argument that selects the provider. */ + settingsTab: string; +} + +/** + * The tray footer account row (Mac `MenuDescriptor` "Switch Account..." or + * "Add Account..."). It replaces the account menus that used to sit inside + * the card, and deep-links to the provider's existing Settings account UI. + */ +export function accountMenuAction( + provider: ProviderUsageSnapshot | null, +): AccountMenuAction | null { + if (!provider || !ACCOUNT_MANAGER_PROVIDERS.has(provider.providerId)) return null; + const hasAccount = + Boolean(provider.accountEmail?.trim()) || hasSuccessfulClaudeCliQuota(provider); + return { + labelKey: hasAccount ? "TrayMenuSwitchAccount" : "AddAccountEllipsis", + settingsTab: providersTabRequest(provider.providerId), + }; +} diff --git a/apps/desktop-tauri/src/lib/cardCost.test.ts b/apps/desktop-tauri/src/lib/cardCost.test.ts new file mode 100644 index 0000000000..2889c6d30d --- /dev/null +++ b/apps/desktop-tauri/src/lib/cardCost.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import type { CostSnapshotBridge } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { cardCostSection } from "./cardCost"; + +const STRINGS: Partial> = { + ExtraUsage: "Extra usage", + QuotaUsageTitle: "Quota usage", + ApiSpendTitle: "API spend", + DetailCostBalance: "Balance", + DetailCostResets: "Resets", + UsedPercent: '{:.0}% used', + CostPeriodLastDays: "Last {} days", + CostPeriodThisMonth: "This month", + PanelToday: "Today", + ProviderLabelMonthly: "Monthly", +}; +const t = (key: LocaleKey) => STRINGS[key] ?? key; + +function cost(overrides: Partial): CostSnapshotBridge { + return { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "", + resetsAt: null, + formattedUsed: "", + formattedLimit: null, + ...overrides, + }; +} + +describe("cardCostSection", () => { + it("returns nothing without a cost", () => { + expect(cardCostSection(null, "detailed", t)).toBeNull(); + }); + + it("hides the block when the summary style is hidden", () => { + expect(cardCostSection(cost({ used: 3, limit: 10 }), "hidden", t)).toBeNull(); + }); + + it("keeps always-visible API spend under the hidden style", () => { + const section = cardCostSection( + cost({ used: 4, period: "Last 30 days", alwaysVisible: true }), + "hidden", + t, + ); + expect(section).toEqual({ + title: "API spend", + spendLine: "Last 30 days: $4.00", + percentUsed: null, + percentLine: null, + balanceLine: null, + inline: false, + }); + }); + + it("uses Last 30 days as the API spend default period", () => { + const section = cardCostSection( + cost({ used: 1.5, alwaysVisible: true, formattedUsed: "$1.50" }), + "detailed", + t, + ); + expect(section?.spendLine).toBe("Last 30 days: $1.50"); + }); + + it("renders a capped spend as Extra usage with a percent", () => { + const section = cardCostSection( + cost({ + used: 12.5, + limit: 50, + period: "Monthly cap", + formattedUsed: "$12.50", + formattedLimit: "$50.00", + }), + "detailed", + t, + ); + expect(section).toEqual({ + title: "Extra usage", + spendLine: "Monthly cap: $12.50 / $50.00", + percentUsed: 25, + percentLine: "25% used", + balanceLine: null, + inline: false, + }); + }); + + it("defaults a capped spend period to This month and adds the balance", () => { + const section = cardCostSection( + cost({ used: 60, limit: 50, balance: 8 }), + "detailed", + t, + ); + expect(section?.spendLine).toBe("This month: $60.00 / $50.00"); + expect(section?.percentUsed).toBe(100); + expect(section?.percentLine).toBe("100% used"); + expect(section?.balanceLine).toBe("Balance: $8.00"); + }); + + it("drops the balance line in the compact summary style", () => { + const section = cardCostSection(cost({ used: 1, limit: 4, balance: 8 }), "compact", t); + expect(section?.balanceLine).toBeNull(); + }); + + it("titles a Quota currency as Quota usage with whole numbers", () => { + const section = cardCostSection( + cost({ used: 120.4, limit: 500, currencyCode: "Quota", period: "This month" }), + "detailed", + t, + ); + expect(section?.title).toBe("Quota usage"); + expect(section?.spendLine).toBe("This month: 120 / 500"); + expect(section?.percentLine).toBe("24% used"); + }); + + it("shows a balance-only cost inline", () => { + const section = cardCostSection( + cost({ used: 0, balance: 31.58, formattedBalance: "$31.58" }), + "detailed", + t, + ); + expect(section).toEqual({ + title: "Extra usage", + spendLine: "Balance: $31.58", + percentUsed: null, + percentLine: null, + balanceLine: null, + inline: true, + }); + }); + + it("reads a credits-labelled cost without a cap as a balance", () => { + const section = cardCostSection( + cost({ used: 125.5, period: "Credits" }), + "detailed", + t, + ); + expect(section?.title).toBe("Extra usage"); + expect(section?.spendLine).toBe("Balance: $125.50"); + expect(section?.inline).toBe(true); + }); + + it("shows an uncapped spend with its period", () => { + const section = cardCostSection( + cost({ used: 2, currencyCode: "EUR", period: "Monthly", currencySymbol: "€" }), + "detailed", + t, + ); + expect(section?.title).toBe("Extra usage"); + expect(section?.spendLine).toBe("Monthly: €2.00"); + expect(section?.inline).toBe(true); + }); +}); diff --git a/apps/desktop-tauri/src/lib/cardCost.ts b/apps/desktop-tauri/src/lib/cardCost.ts new file mode 100644 index 0000000000..5d2eafa9f2 --- /dev/null +++ b/apps/desktop-tauri/src/lib/cardCost.ts @@ -0,0 +1,118 @@ +import type { CostSnapshotBridge, CostSummaryDisplayStyle } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { localizeProviderText } from "./providerText"; + +type Translate = (key: LocaleKey) => string; + +/** + * The card's cost block, following upstream `MenuCardView+Costs.swift`: + * a capped spend is "Extra usage" (or "Quota usage") with "period: used / limit" + * and "N% used"; metered spend is "API spend"; a balance is one inline line. + */ +export interface CardCostSection { + title: string; + spendLine: string; + /** 0..100 for the bar; null when the block has no cap. */ + percentUsed: number | null; + percentLine: string | null; + balanceLine: string | null; + /** Title and value on one line, with no bar. */ + inline: boolean; +} + +const currencyFormatters = new Map(); + +export function formatCostAmount(amount: number, code: string): string { + try { + let formatter = currencyFormatters.get(code); + if (!formatter) { + formatter = new Intl.NumberFormat("en-US", { style: "currency", currency: code }); + currencyFormatters.set(code, formatter); + } + return formatter.format(amount); + } catch { + return `${code} ${amount.toFixed(2)}`; + } +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +/** Mac `localizedPeriodLabel`: the common English periods map to locale strings. */ +function periodLabel(raw: string | null | undefined, fallback: string, t: Translate): string { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) return fallback; + switch (trimmed.toLowerCase()) { + case "last 30 days": + return t("CostPeriodLastDays").replace("{}", "30"); + case "this month": + return t("CostPeriodThisMonth"); + case "today": + return t("PanelToday"); + default: + return localizeProviderText(trimmed, t) || trimmed; + } +} + +/** A cost whose period names a balance carries the balance in `used`. */ +function periodNamesBalance(period: string): boolean { + const normalized = period.trim().toLowerCase(); + return normalized === "credits" || normalized.endsWith("balance"); +} + +export function cardCostSection( + cost: CostSnapshotBridge | null | undefined, + style: CostSummaryDisplayStyle | undefined, + t: Translate, +): CardCostSection | null { + if (!cost) return null; + if (style === "hidden" && cost.alwaysVisible !== true) return null; + + const isQuota = cost.currencyCode === "Quota"; + const money = (value: number, formatted?: string | null) => + isQuota ? value.toFixed(0) : formatted || formatCostAmount(value, cost.currencyCode); + const balanceText = (value: number) => + `${t("DetailCostBalance")}: ${money(value, cost.formattedBalance)}`; + const limit = cost.limit ?? 0; + + if (limit > 0) { + const percentUsed = clampPercent((cost.used / limit) * 100); + return { + title: isQuota ? t("QuotaUsageTitle") : t("ExtraUsage"), + spendLine: `${periodLabel(cost.period, t("CostPeriodThisMonth"), t)}: ${money(cost.used, cost.formattedUsed)} / ${money(limit, cost.formattedLimit)}`, + percentUsed, + percentLine: t("UsedPercent").replace(/\{[^}]*\}/, percentUsed.toFixed(0)), + balanceLine: + style !== "compact" && cost.balance != null ? balanceText(cost.balance) : null, + inline: false, + }; + } + + if (cost.alwaysVisible === true) { + return { + title: t("ApiSpendTitle"), + spendLine: `${periodLabel(cost.period, t("CostPeriodLastDays").replace("{}", "30"), t)}: ${money(cost.used, cost.formattedUsed)}`, + percentUsed: null, + percentLine: null, + balanceLine: null, + inline: false, + }; + } + + const inline = (spendLine: string): CardCostSection => ({ + title: t("ExtraUsage"), + spendLine, + percentUsed: null, + percentLine: null, + balanceLine: null, + inline: true, + }); + if (cost.balance != null) return inline(balanceText(cost.balance)); + if (periodNamesBalance(cost.period)) { + return inline(`${t("DetailCostBalance")}: ${money(cost.used, cost.formattedUsed)}`); + } + return inline( + `${periodLabel(cost.period, t("CostPeriodThisMonth"), t)}: ${money(cost.used, cost.formattedUsed)}`, + ); +} diff --git a/apps/desktop-tauri/src/lib/inlineDashboard.test.ts b/apps/desktop-tauri/src/lib/inlineDashboard.test.ts new file mode 100644 index 0000000000..359539b746 --- /dev/null +++ b/apps/desktop-tauri/src/lib/inlineDashboard.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import type { + DailyCostPoint, + ProviderLocalUsageSummary, + QuotaWindowHistoryPoint, +} from "../types/bridge"; +import { inlineDashboardModel, shortModelName } from "./inlineDashboard"; + +const STRINGS: Partial> = { + PanelToday: "Today", + PanelLatest: "Latest", + PanelLatestTokens: "Latest tokens", + PanelPeriodCost: "{} cost", + PanelPeriodTokens: "{} tokens", + CostPeriodShortDays: "{}d", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortAll: "All", + PanelEstimatedValue: "Est. {}", + PanelCurrentWindow: "Current window", + PanelCurrentWindowTokens: "Current window tokens", + PanelPreviousWindow: "Previous window", + PanelWindowsAgo: "{} windows ago", + PanelPartialEstimate: "Partial estimate", + PanelTopModelPrefix: "Top model", + PanelEstimatedFromLocalLogs: "Estimated from local logs", + IncompleteRequestsLabel: "Incomplete", + IncompleteRequestsDetail: "{} requests had no final usage", + UsageSpendTokens: "tokens", +}; +const t = (key: LocaleKey) => STRINGS[key] ?? key; + +function summary(overrides: Partial = {}): ProviderLocalUsageSummary { + return { + todayCost: 1.25, + thirtyDayCost: 40, + thirtyDayTokens: 12_400_000, + periodCost: 40, + periodTokens: 12_400_000, + reportingPeriod: "rolling:30", + latestTokens: 380_000, + topModel: "gpt-5.1-codex", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 0, + ...overrides, + }; +} + +function week(overrides: Partial): QuotaWindowHistoryPoint { + return { + offset: 0, + start: "2026-10-06T09:24:00Z", + end: "2026-10-13T09:24:00Z", + totalTokens: 2_100_000, + totalCostUsd: 6.5, + tokensAreComplete: true, + costIsComplete: true, + entryCount: 3, + boundariesAreEstimated: false, + ...overrides, + }; +} + +const days: DailyCostPoint[] = [ + { date: "2026-10-09", value: 2 }, + { date: "2026-10-10", value: null }, + { date: "2026-10-11", value: 8 }, +]; + +const base = { + providerId: "codex", + costHistory: days, + tokensHistory: [{ date: "2026-10-11", tokens: 380_000 }], + t, + locale: "en-US", + timeZone: "UTC", +}; + +describe("inlineDashboardModel", () => { + it("returns nothing without a local usage summary", () => { + expect(inlineDashboardModel({ ...base, summary: null, quotaHistory: null })).toBeNull(); + }); + + it("orders the KPIs Today, period cost, latest tokens, period tokens without a current window", () => { + const model = inlineDashboardModel({ ...base, summary: summary(), quotaHistory: null }); + expect(model?.kpis).toEqual([ + { title: "Today", value: "$1.25", emphasis: true }, + { title: "30d cost", value: "$40.00", emphasis: false }, + { title: "Latest tokens", value: "380K", emphasis: false }, + { title: "30d tokens", value: "12.4M", emphasis: false }, + ]); + expect(model?.windows).toEqual([]); + }); + + it("inserts the current window KPIs when the history is longer than a week", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary(), + quotaHistory: [week({}), week({ offset: 1, totalCostUsd: 9, totalTokens: 3_000_000 })], + }); + expect(model?.kpis.map((kpi) => [kpi.title, kpi.value, kpi.emphasis])).toEqual([ + ["Today", "$1.25", true], + ["Current window", "$6.50", true], + ["Latest tokens", "380K", false], + ["Current window tokens", "2.1M", false], + ["30d cost", "$40.00", false], + ["30d tokens", "12.4M", false], + ]); + }); + + it("relabels the history KPIs as the current window for a 7-day history", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary({ reportingPeriod: "rolling:7" }), + quotaHistory: [week({ costIsComplete: false })], + }); + expect(model?.kpis.map((kpi) => [kpi.title, kpi.value, kpi.emphasis])).toEqual([ + ["Today", "$1.25", true], + ["Current window", "≥ $6.50", true], + ["Latest tokens", "380K", false], + ["Current window tokens", "2.1M", false], + ]); + }); + + it("prefixes estimated window boundaries with Est.", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary(), + quotaHistory: [week({ boundariesAreEstimated: true })], + }); + expect(model?.kpis[1].title).toBe("Est. Current window"); + expect(model?.kpis[3].title).toBe("Est. Current window tokens"); + expect(model?.windows[0].range).toBe("Est. Oct 6, 9:24 AM – Oct 13, 9:24 AM"); + }); + + it("builds up to four recent window rows with cost and tokens", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary(), + quotaHistory: [ + week({}), + week({ offset: 1, tokensAreComplete: false }), + week({ offset: 2, entryCount: 0 }), + week({ offset: 3, totalTokens: null, totalCostUsd: 1 }), + week({ offset: 4 }), + ], + }); + expect(model?.windows.map((row) => [row.title, row.value, row.note])).toEqual([ + ["Current window", "$6.50 · 2.1M", null], + ["Previous window", "$6.50 · ≥ 2.1M", "Partial estimate"], + ["3 windows ago", "$1.00", null], + ]); + expect(model?.windows[0].range).toBe("Oct 6, 9:24 AM – Oct 13, 9:24 AM"); + }); + + it("keeps an empty current window but drops empty past windows", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary(), + quotaHistory: [ + week({ entryCount: 0, totalCostUsd: null, totalTokens: null }), + week({ offset: 1, entryCount: 0 }), + ], + }); + expect(model?.windows.map((row) => [row.title, row.value])).toEqual([ + ["Current window", "—"], + ]); + }); + + it("marks incomplete history totals and adds the incomplete note", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary({ incompleteRequestCount: 2 }), + quotaHistory: null, + }); + expect(model?.kpis[1].value).toBe("$40.00 · Incomplete"); + expect(model?.kpis[3].value).toBe("12.4M · Incomplete"); + expect(model?.detailLines).toContain("Incomplete · 2 requests had no final usage"); + }); + + it("lists the top model and the estimate note", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary({ topModel: "claude-opus-4-5-20251101-extended" }), + quotaHistory: null, + }); + expect(model?.detailLines).toEqual([ + "Top model: claude-opus-4-5-20251101-…", + "Estimated from local logs", + ]); + }); + + it("builds chart points with a compact currency scale", () => { + const model = inlineDashboardModel({ ...base, summary: summary(), quotaHistory: null }); + expect(model?.points.map((point) => [point.date, point.value])).toEqual([ + ["2026-10-09", 2], + ["2026-10-10", null], + ["2026-10-11", 8], + ]); + expect(model?.maxValue).toBe(8); + expect(model?.scaleLabel).toBe("$8"); + expect(model?.points[2].title).toBe("2026-10-11: $8.00 · 380K tokens"); + }); + + it("hides the chart when there is no cost history", () => { + const model = inlineDashboardModel({ + ...base, + costHistory: [], + summary: summary(), + quotaHistory: null, + }); + expect(model?.points).toEqual([]); + expect(model?.scaleLabel).toBeNull(); + }); + + it("shows a tokens-only dashboard for Muse", () => { + const model = inlineDashboardModel({ + ...base, + providerId: "muse", + summary: summary({ todayCost: null, periodCost: null }), + quotaHistory: null, + }); + expect(model?.kpis).toEqual([ + { title: "Latest", value: "380K tokens", emphasis: true }, + { title: "30d tokens", value: "12.4M", emphasis: false }, + ]); + }); + + it("uses dashes for missing values", () => { + const model = inlineDashboardModel({ + ...base, + summary: summary({ todayCost: null, periodCost: null, latestTokens: null, periodTokens: null }), + quotaHistory: null, + }); + expect(model?.kpis.map((kpi) => kpi.value)).toEqual(["—", "—", "—", "—"]); + }); +}); + +describe("shortModelName", () => { + it("keeps names up to 26 characters", () => { + expect(shortModelName(" gpt-5.1-codex ")).toBe("gpt-5.1-codex"); + expect(shortModelName("a".repeat(26))).toBe("a".repeat(26)); + }); + + it("truncates longer names to 25 characters and an ellipsis", () => { + expect(shortModelName("b".repeat(27))).toBe(`${"b".repeat(25)}…`); + }); +}); diff --git a/apps/desktop-tauri/src/lib/inlineDashboard.ts b/apps/desktop-tauri/src/lib/inlineDashboard.ts new file mode 100644 index 0000000000..365e977317 --- /dev/null +++ b/apps/desktop-tauri/src/lib/inlineDashboard.ts @@ -0,0 +1,306 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { + DailyCostPoint, + DailyTokenPoint, + ProviderLocalUsageSummary, + QuotaWindowHistoryPoint, +} from "../types/bridge"; +import { parseCostPeriod, periodCostLabel, periodTokensLabel } from "./costPeriod"; +import { formatCostAmount } from "./cardCost"; + +type Translate = (key: LocaleKey) => string; + +/** + * The card's inline usage dashboard, following upstream + * `InlineUsageDashboardContent.swift`: a KPI grid, a 58 px cost histogram, + * the recent quota windows, and short detail lines. + */ +export interface DashboardKpi { + title: string; + value: string; + emphasis: boolean; +} + +export interface DashboardPoint { + date: string; + value: number | null; + title: string; +} + +export interface DashboardWindowRow { + id: string; + title: string; + range: string; + value: string; + note: string | null; +} + +export interface InlineDashboardModel { + kpis: DashboardKpi[]; + points: DashboardPoint[]; + maxValue: number; + scaleLabel: string | null; + windows: DashboardWindowRow[]; + detailLines: string[]; +} + +export interface InlineDashboardInput { + providerId: string; + summary: ProviderLocalUsageSummary | null | undefined; + costHistory: DailyCostPoint[]; + tokensHistory: DailyTokenPoint[]; + quotaHistory: QuotaWindowHistoryPoint[] | null | undefined; + t: Translate; + /** Formatting overrides, for deterministic tests. */ + locale?: string; + timeZone?: string; + now?: Date; +} + +/** Upstream shows at most four quota windows (`weekCount: 4`). */ +const MAX_WINDOWS = 4; +/** The histogram stays a fixed 30 days, whatever the History window is. */ +const CHART_DAYS = 30; + +const compactCount0 = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }); +const compactCount1 = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); + +function compactCount(value: number | null | undefined): string { + if (value == null || value <= 0) return "—"; + return (value >= 1_000_000 ? compactCount1 : compactCount0).format(value); +} + +function compactCurrency(value: number): string { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); + } catch { + return formatCostAmount(value, "USD"); + } +} + +function fill(template: string, value: string): string { + return template.replace(/\{[^}]*\}/, value); +} + +/** Upstream `quotaMetricValue`: an incomplete subtotal reads as a lower bound. */ +function boundValue(value: string | null, complete: boolean): string { + if (value == null) return "—"; + return complete ? value : `≥ ${value}`; +} + +export function shortModelName(name: string): string { + const trimmed = name.trim(); + return trimmed.length > 26 ? `${trimmed.slice(0, 25)}…` : trimmed; +} + +/** Day count of the History window; month-to-date counts the days so far. */ +function historyDays(reportingPeriod: string, now: Date): number { + const parsed = parseCostPeriod(reportingPeriod) ?? { kind: "rolling", days: 30 }; + switch (parsed.kind) { + case "rolling": + return parsed.days; + case "monthToDate": + return now.getDate(); + case "all": + return Number.POSITIVE_INFINITY; + } +} + +function rangeLabel( + start: string, + end: string, + locale: string | undefined, + timeZone: string | undefined, +): string { + const startDate = new Date(start); + const endDate = new Date(end); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + return `${start} – ${end}`; + } + const dayKey = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const sameDay = dayKey.format(startDate) === dayKey.format(endDate); + const sameYear = dayKey.format(startDate).slice(0, 4) === dayKey.format(endDate).slice(0, 4); + const time: Intl.DateTimeFormatOptions = { timeZone, hour: "numeric", minute: "2-digit" }; + const dated = new Intl.DateTimeFormat(locale, { + ...time, + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + }); + const endText = sameDay + ? new Intl.DateTimeFormat(locale, time).format(endDate) + : dated.format(endDate); + return `${dated.format(startDate)} – ${endText}`; +} + +function windowTitle(offset: number, t: Translate): string { + if (offset === 0) return t("PanelCurrentWindow"); + if (offset === 1) return t("PanelPreviousWindow"); + return fill(t("PanelWindowsAgo"), String(offset)); +} + +function windowRow( + week: QuotaWindowHistoryPoint, + input: InlineDashboardInput, +): DashboardWindowRow { + const { t } = input; + const cost = boundValue( + week.totalCostUsd == null ? null : formatCostAmount(week.totalCostUsd, "USD"), + week.costIsComplete, + ); + const value = + week.totalTokens != null + ? `${cost} · ${boundValue(compactCount(week.totalTokens), week.tokensAreComplete)}` + : cost; + const range = rangeLabel(week.start, week.end, input.locale, input.timeZone); + const partial = + (!week.costIsComplete && week.totalCostUsd != null) || + (!week.tokensAreComplete && week.totalTokens != null); + return { + id: `${week.offset}-${week.start}`, + title: windowTitle(week.offset, t), + range: week.boundariesAreEstimated ? fill(t("PanelEstimatedValue"), range) : range, + value, + note: partial ? t("PanelPartialEstimate") : null, + }; +} + +function chartPoints(input: InlineDashboardInput): DashboardPoint[] { + const tokensByDate = new Map(input.tokensHistory.map((point) => [point.date, point.tokens])); + return input.costHistory.slice(-CHART_DAYS).map((point) => { + const tokens = tokensByDate.get(point.date); + const title = + point.value == null && tokens == null + ? point.date + : `${point.date}: ${point.value == null ? "—" : formatCostAmount(point.value, "USD")} · ${ + tokens == null ? "—" : `${compactCount(tokens)} ${input.t("UsageSpendTokens")}` + }`; + return { date: point.date, value: point.value, title }; + }); +} + +export function inlineDashboardModel(input: InlineDashboardInput): InlineDashboardModel | null { + const { summary, t } = input; + if (!summary) return null; + + const incompleteCount = summary.incompleteRequestCount ?? 0; + const incompleteSuffix = incompleteCount > 0 ? ` · ${t("IncompleteRequestsLabel")}` : ""; + const money = (value: number | null) => (value == null ? "—" : formatCostAmount(value, "USD")); + const period = summary.reportingPeriod; + const periodTokens = { + title: periodTokensLabel(period, t), + value: + compactCount(summary.periodTokens) + (summary.periodTokens != null ? incompleteSuffix : ""), + emphasis: false, + }; + + const detailLines: string[] = []; + if (incompleteCount > 0) { + detailLines.push( + `${t("IncompleteRequestsLabel")} · ${fill(t("IncompleteRequestsDetail"), String(incompleteCount))}`, + ); + } + if (summary.topModel) { + detailLines.push(`${t("PanelTopModelPrefix")}: ${shortModelName(summary.topModel)}`); + } + if (summary.estimateNote) { + detailLines.push( + summary.estimateNote === "Estimated from local logs" + ? t("PanelEstimatedFromLocalLogs") + : summary.estimateNote, + ); + } + + const points = chartPoints(input); + const maxValue = Math.max(0, ...points.flatMap((point) => (point.value == null ? [] : [point.value]))); + const scaleLabel = points.length > 0 && maxValue > 0 ? compactCurrency(maxValue) : null; + + // Tokens-only providers (Muse) have no cost; upstream `tokenHistoryInlineDashboard`. + if (input.providerId === "muse") { + return { + kpis: [ + { + title: t("PanelLatest"), + value: + summary.latestTokens != null && summary.latestTokens > 0 + ? fill(t("PanelPeriodTokens"), compactCount(summary.latestTokens)) + : "—", + emphasis: true, + }, + periodTokens, + ], + points: [], + maxValue: 0, + scaleLabel: null, + windows: [], + detailLines, + }; + } + + const windows = (input.quotaHistory ?? []) + .filter((week) => week.offset < MAX_WINDOWS) + .filter((week) => week.offset === 0 || week.entryCount > 0) + .sort((a, b) => a.offset - b.offset) + .map((week) => windowRow(week, input)); + + const today: DashboardKpi = { title: t("PanelToday"), value: money(summary.todayCost), emphasis: true }; + const periodCost: DashboardKpi = { + title: periodCostLabel(period, t), + value: money(summary.periodCost) + (summary.periodCost != null ? incompleteSuffix : ""), + emphasis: false, + }; + const latestTokens: DashboardKpi = { + title: t("PanelLatestTokens"), + value: compactCount(summary.latestTokens), + emphasis: false, + }; + + const current = input.quotaHistory?.find((week) => week.offset === 0); + const days = historyDays(period, input.now ?? new Date()); + if (current && days >= 7) { + const estimated = current.boundariesAreEstimated; + const titled = (text: string) => (estimated ? fill(t("PanelEstimatedValue"), text) : text); + const windowCost: DashboardKpi = { + title: titled(t("PanelCurrentWindow")), + value: boundValue( + current.totalCostUsd == null ? null : formatCostAmount(current.totalCostUsd, "USD"), + current.costIsComplete, + ), + emphasis: true, + }; + const windowTokens: DashboardKpi = { + title: titled(t("PanelCurrentWindowTokens")), + value: boundValue( + current.totalTokens == null ? null : compactCount(current.totalTokens), + current.tokensAreComplete, + ), + emphasis: false, + }; + // A 7-day history is the window itself: relabel instead of repeating it. + const kpis = + days > 7 + ? [today, windowCost, latestTokens, windowTokens, periodCost, periodTokens] + : [today, windowCost, latestTokens, windowTokens]; + return { kpis, points, maxValue, scaleLabel, windows, detailLines }; + } + + return { + kpis: [today, periodCost, latestTokens, periodTokens], + points, + maxValue, + scaleLabel, + windows, + detailLines, + }; +} diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 388a5caa6f..fd02202f54 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -10,7 +10,7 @@ import { useSettings } from "../hooks/useSettings"; import { useSurfaceTarget } from "../hooks/useSurfaceMode"; import { useLocale } from "../hooks/useLocale"; import { closeSettingsWindow, getWorkAreaRect, setSurfaceMode } from "../lib/tauri"; -import { TAB_META, isSettingsTab } from "./settings/settingsTabs"; +import { TAB_META, isSettingsTab, parseSettingsTabRequest } from "./settings/settingsTabs"; import GeneralTab from "./settings/tabs/GeneralTab"; import DisplayTab from "./settings/tabs/DisplayTab"; import AdvancedTab from "./settings/tabs/AdvancedTab"; @@ -136,29 +136,47 @@ async function applySettingsWindowSize() { .catch(() => {}); } -export default function Settings({ state, initialTab: propTab }: { state: BootstrapState; initialTab?: string }) { +export default function Settings({ + state, + initialTab: propTab, + tabRequestId = 0, +}: { + state: BootstrapState; + /** Raw tab request: a tab id, or `providers:` to select a provider. */ + initialTab?: string; + /** Changes on every window request, so a repeated deep link applies again. */ + tabRequestId?: number; +}) { const { settings, saving, error, update } = useSettings(state.settings); const { t } = useLocale(); const shellTarget = useSurfaceTarget("settings"); + const propRequest = parseSettingsTabRequest(propTab); const initialTab: SettingsTabId = - propTab && isSettingsTab(propTab) - ? propTab + propRequest + ? propRequest.tab : shellTarget?.kind === "settings" && isSettingsTab(shellTarget.tab) ? shellTarget.tab : "general"; const [activeTab, setActiveTab] = useState(initialTab); + const [providerFocus, setProviderFocus] = useState<{ id: string | null; request: number }>({ + id: propRequest?.providerId ?? null, + request: tabRequestId, + }); const shellTab: SettingsTabId | null = shellTarget?.kind === "settings" && isSettingsTab(shellTarget.tab) ? shellTarget.tab : null; - const [prevPropTab, setPrevPropTab] = useState(propTab); + const [prevPropRequest, setPrevPropRequest] = useState({ tab: propTab, id: tabRequestId }); const [prevShellTab, setPrevShellTab] = useState(shellTab); // Adjust local tab during render when external drivers change (no effect sync). - if (propTab !== prevPropTab) { - setPrevPropTab(propTab); - if (propTab && isSettingsTab(propTab)) { - setActiveTab(propTab); + if (propTab !== prevPropRequest.tab || tabRequestId !== prevPropRequest.id) { + setPrevPropRequest({ tab: propTab, id: tabRequestId }); + if (propRequest) { + setActiveTab(propRequest.tab); + if (propRequest.providerId) { + setProviderFocus((focus) => ({ id: propRequest.providerId, request: focus.request + 1 })); + } } } if (shellTab !== prevShellTab) { @@ -250,6 +268,8 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst providers={state.providers} set={set} saving={saving} + focusProviderId={providerFocus.id} + focusRequest={providerFocus.request} /> )} {activeTab === "notifications" && ( diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 37b1f77dae..3a14c67168 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -310,6 +310,7 @@ describe("TrayPanel provider grid", () => { MenuSettings: "Settings...", PanelMenu: "Menu", TrayMenuSwitchAccount: "Switch Account...", + AddAccountEllipsis: "Add Account...", TrayMenuUsageDashboard: "Usage Dashboard", TrayMenuStatusPage: "Status Page", PanelAllProviders: "All providers", @@ -541,7 +542,15 @@ describe("TrayPanel provider grid", () => { fireEvent.click(await screen.findByRole("button", { name: "Switch Account..." })); - expect(tauriMocks.openSettingsWindow).toHaveBeenCalledWith("providers"); + expect(tauriMocks.openSettingsWindow).toHaveBeenCalledWith("providers:claude"); + }); + + it("offers Add Account... for a Codex card with no account", async () => { + renderTrayPanel([provider("codex", "Codex", 35)]); + + fireEvent.click(await screen.findByRole("button", { name: "Add Account..." })); + + expect(tauriMocks.openSettingsWindow).toHaveBeenCalledWith("providers:codex"); }); it("localizes static tray panel labels in Japanese", async () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 7c8b22ee6a..12f63f1c01 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,5 +1,9 @@ import { useCallback, useEffect, useReducer, useState } from "react"; -import type { SettingsSnapshot, SettingsUpdate } from "../../../types/bridge"; +import type { + ProviderUsageSnapshot, + SettingsSnapshot, + SettingsUpdate, +} from "../../../types/bridge"; import { useLocale } from "../../../hooks/useLocale"; import { providerAllowsPace } from "../../../lib/providerPace"; import { @@ -57,6 +61,8 @@ import { MuseBrowserTeamSection } from "./sections/MuseBrowserTeamSection"; interface Props { providerId: string | null; + /** Live tray snapshot for `providerId`; feeds the details moved out of the tray card. */ + liveSnapshot?: ProviderUsageSnapshot | null; cookieDomain?: string | null; resetTimeRelative: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; @@ -78,6 +84,7 @@ interface Props { */ export function ProviderDetailPane({ providerId, + liveSnapshot = null, cookieDomain = null, resetTimeRelative, providerMetrics, diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts index 404cdd2e37..c226aa6594 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { SettingsTabId } from "../../types/bridge"; -import { isSettingsTab, TAB_META } from "./settingsTabs"; +import { isSettingsTab, parseSettingsTabRequest, providersTabRequest, TAB_META } from "./settingsTabs"; describe("isSettingsTab", () => { it.each(["general", "providers", "notifications", "menuBar", "menu", "usageSpend", "advanced", "about"])( @@ -53,3 +53,49 @@ describe("TAB_META", () => { expect(new Set(TAB_META.map((m) => m.id)).size).toBe(TAB_META.length); }); }); + +describe("parseSettingsTabRequest", () => { + it("reads a plain tab id with no provider", () => { + expect(parseSettingsTabRequest("providers")).toEqual({ tab: "providers", providerId: null }); + expect(parseSettingsTabRequest("about")).toEqual({ tab: "about", providerId: null }); + }); + + it("reads a provider deep link on the Providers tab", () => { + expect(parseSettingsTabRequest("providers:claude")).toEqual({ + tab: "providers", + providerId: "claude", + }); + }); + + it("ignores an empty provider id", () => { + expect(parseSettingsTabRequest("providers:")).toEqual({ tab: "providers", providerId: null }); + }); + + it("keeps an unknown provider id for the tab to resolve", () => { + expect(parseSettingsTabRequest("providers:nope")).toEqual({ + tab: "providers", + providerId: "nope", + }); + }); + + it("drops a provider id on any other tab", () => { + expect(parseSettingsTabRequest("general:claude")).toEqual({ tab: "general", providerId: null }); + }); + + it("returns null for an unknown tab", () => { + expect(parseSettingsTabRequest("dashboard")).toBeNull(); + expect(parseSettingsTabRequest("dashboard:claude")).toBeNull(); + expect(parseSettingsTabRequest("")).toBeNull(); + expect(parseSettingsTabRequest(undefined)).toBeNull(); + }); +}); + +describe("providersTabRequest", () => { + it("builds the deep link that parseSettingsTabRequest reads", () => { + expect(providersTabRequest("codex")).toBe("providers:codex"); + expect(parseSettingsTabRequest(providersTabRequest("grok"))).toEqual({ + tab: "providers", + providerId: "grok", + }); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts index 9ca0878c9f..a63ac0f03c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts @@ -21,3 +21,28 @@ export interface TabProps { export function isSettingsTab(value: string): value is SettingsTabId { return TAB_META.some((t) => t.id === value); } + +/** A Settings window request: a tab plus, on Providers, the provider to select. */ +export interface SettingsTabRequest { + tab: SettingsTabId; + providerId: string | null; +} + +/** + * Parse a raw `open_settings_window` tab argument. `providers:` selects a + * provider on the Providers tab; the tab id still has to be one of `TAB_META`. + * Unknown tabs return null so callers keep their own fallback. + */ +export function parseSettingsTabRequest(raw: string | null | undefined): SettingsTabRequest | null { + if (!raw) return null; + const separator = raw.indexOf(":"); + const tab = separator < 0 ? raw : raw.slice(0, separator); + if (!isSettingsTab(tab)) return null; + const providerId = separator < 0 ? "" : raw.slice(separator + 1).trim(); + return { tab, providerId: tab === "providers" && providerId ? providerId : null }; +} + +/** The `open_settings_window` argument that opens Settings > Providers on one provider. */ +export function providersTabRequest(providerId: string): string { + return `providers:${providerId}`; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx index 7d17774a44..0995c0aa23 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx @@ -19,7 +19,17 @@ vi.mock("../../../lib/tauri", () => ({ reorderProviders: vi.fn().mockResolvedValue(undefined), })); vi.mock("../providers/ProviderDetailPane", () => ({ - ProviderDetailPane: () => null, + ProviderDetailPane: ({ + providerId, + liveSnapshot, + }: { + providerId: string | null; + liveSnapshot?: { providerId: string } | null; + }) => ( +
+ {providerId} +
+ ), })); import ProvidersTab from "./ProvidersTab"; @@ -88,4 +98,81 @@ describe("ProvidersTab", () => { expect(screen.getByText("42%")).toBeInTheDocument(); }); + + const claude: ProviderCatalogEntry = { id: "claude", displayName: "Claude", cookieDomain: null }; + const twoProviderSettings = { + ...settings, + enabledProviders: [provider.id, claude.id], + } as SettingsSnapshot; + + it("selects the deep-linked provider and follows a later request", () => { + hookMocks.useProviders.mockReturnValue({ providers: [] }); + const { rerender } = render( + , + ); + expect(screen.getByTestId("detail-pane")).toHaveTextContent("claude"); + + rerender( + , + ); + expect(screen.getByTestId("detail-pane")).toHaveTextContent("codex"); + }); + + it("falls back to the first provider for an unknown deep link", () => { + hookMocks.useProviders.mockReturnValue({ providers: [] }); + render( + , + ); + expect(screen.getByTestId("detail-pane")).toHaveTextContent("codex"); + }); + + it("hands the pane the selected provider's live snapshot only", () => { + const live = (providerId: string) => + ({ + providerId, + displayName: providerId, + primary: rateWindow(10), + selectedMetric: rateWindow(10), + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + error: null, + }) as unknown as ProviderUsageSnapshot; + const codexSnapshot = live("codex"); + const claudeSnapshot = live("claude"); + hookMocks.useProviders.mockReturnValue({ providers: [codexSnapshot, claudeSnapshot] }); + render( + , + ); + expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-live", "claude"); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index 791fb00adc..4bde7b649c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -22,6 +22,10 @@ interface ProvidersTabProps { providers: ProviderCatalogEntry[]; set: (patch: SettingsUpdate) => void; saving: boolean; + /** Provider to select, from a `providers:` Settings request (tray "Switch Account..."). */ + focusProviderId?: string | null; + /** Changes on every Settings request, so repeating the same deep link reselects it. */ + focusRequest?: number; } export default function ProvidersTab({ @@ -29,18 +33,29 @@ export default function ProvidersTab({ providers, set, saving, + focusProviderId = null, + focusRequest = 0, }: ProvidersTabProps) { const { t } = useLocale(); const { providers: snapshots } = useProviders(); const [selectedId, setSelectedId] = useState( - providers[0]?.id ?? null, + focusProviderId ?? providers[0]?.id ?? null, ); + const [prevFocusRequest, setPrevFocusRequest] = useState(focusRequest); // Locally-owned catalog order so drag-reorder feels instant before the // backend `reorder_providers` round-trip settles. const [orderedProviders, setOrderedProviders] = useState(providers); const [prevProviders, setPrevProviders] = useState(providers); const [searchText, setSearchText] = useState(""); + // A new deep link reselects its provider and clears a search that would hide it. + if (focusRequest !== prevFocusRequest) { + setPrevFocusRequest(focusRequest); + if (focusProviderId) { + setSelectedId(focusProviderId); + setSearchText(""); + } + } if (providers !== prevProviders) { setPrevProviders(providers); setOrderedProviders(providers); @@ -120,6 +135,8 @@ export default function ProvidersTab({ const selectedEntry = orderedProviders.find((p) => p.id === resolvedSelectedId) ?? null; + const liveSnapshot = + snapshots.find((snapshot) => snapshot.providerId === resolvedSelectedId) ?? null; return (
@@ -135,6 +152,7 @@ export default function ProvidersTab({ /> u16 { 100 } +/// Smallest Panel scale; `apps/desktop-tauri/src/lib/trayScale.ts` mirrors it. +pub const TRAY_SCALE_PERCENT_MIN: u16 = 100; +/// Largest Panel scale; `apps/desktop-tauri/src/lib/trayScale.ts` mirrors it. +pub const TRAY_SCALE_PERCENT_MAX: u16 = 200; + pub fn clamp_tray_scale_percent(value: u16) -> u16 { - value.clamp(100, 200) + value.clamp(TRAY_SCALE_PERCENT_MIN, TRAY_SCALE_PERCENT_MAX) } fn default_float_bar_opacity() -> u8 { diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index 022d584530..b0a2d9080f 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -83,7 +83,10 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("theme", typed::), ("ui_language", typed::), ("window_scale_percent", uint_in::<100, 250>), - ("tray_scale_percent", uint_in::<100, 200>), + ( + "tray_scale_percent", + uint_in::<{ super::TRAY_SCALE_PERCENT_MIN as u64 }, { super::TRAY_SCALE_PERCENT_MAX as u64 }>, + ), ("float_bar_opacity", uint_in::<30, 100>), ("float_bar_scale", uint_in::<75, 200>), ("float_bar_orientation", |v| { diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 355fd677ab..c0b550f1f1 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -616,3 +616,24 @@ fn file_round_trip_and_size_bound() { Err(PreferencesError::Io(_)) )); } + +#[test] +fn tray_scale_document_bounds_match_the_settings_clamp() { + use crate::settings::{ + TRAY_SCALE_PERCENT_MAX, TRAY_SCALE_PERCENT_MIN, clamp_tray_scale_percent, + }; + assert_eq!(clamp_tray_scale_percent(0), TRAY_SCALE_PERCENT_MIN); + assert_eq!(clamp_tray_scale_percent(u16::MAX), TRAY_SCALE_PERCENT_MAX); + for accepted in [TRAY_SCALE_PERCENT_MIN, TRAY_SCALE_PERCENT_MAX] { + assert!( + parse(json!({ "tray_scale_percent": accepted })).is_ok(), + "{accepted} should be accepted" + ); + } + for rejected in [TRAY_SCALE_PERCENT_MIN - 1, TRAY_SCALE_PERCENT_MAX + 1] { + assert!( + parse(json!({ "tray_scale_percent": rejected })).is_err(), + "{rejected} should be rejected" + ); + } +} From 1d2268623fdeba9aec9cf1529b13173d1a520c2d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:01 +0700 Subject: [PATCH 02/13] Split the tray card into the upstream card anatomy - Card = header, metric rows, key-value sections, inline dashboard, credits / extra usage (upstream UsageMenuCardView). Plan is plain footnote text; errors use --mac-error; --provider-accent is always set. - Account menus become a "Switch Account..." row that opens Settings. - Pace budgets + chart, session forecast, burndown, DeepSeek pricing, pace bars and quota window history move to Settings > Providers. - Switcher and Overview spend summary restyled (spec 3.1, 3.2). - Tray dark-tint overrides replaced by a per-surface --surface-tint-rgb. - New tray-card.css, scoped to .menu-surface--tray and loaded last. --- .../components/ClaudeAccountsMenu.test.tsx | 281 ------ .../src/components/ClaudeAccountsMenu.tsx | 183 ---- .../src/components/CodexAccountsMenu.test.tsx | 419 -------- .../src/components/CodexAccountsMenu.tsx | 301 ------ .../src/components/GrokAccountsMenu.test.tsx | 204 ---- .../src/components/GrokAccountsMenu.tsx | 171 ---- .../src/components/InventoryRows.tsx | 4 +- .../src/components/MenuCard.test.tsx | 191 ++-- .../desktop-tauri/src/components/MenuCard.tsx | 421 ++++---- .../src/components/MenuCardDetails.tsx | 905 ------------------ .../components/MenuCardOpenAIUsage.test.tsx | 108 +-- .../components/ProviderAccountsMenu.test.tsx | 25 - .../src/components/ProviderAccountsMenu.tsx | 33 - .../src/components/ProviderDisplayRow.tsx | 4 +- .../src/components/card/CardHeader.test.tsx | 136 +++ .../src/components/card/CardHeader.tsx | 140 +++ .../src/components/card/CardMetricRow.tsx | 164 ++++ .../src/components/card/DetailSections.tsx | 137 +++ .../src/components/card/ExtraUsageBlock.tsx | 80 ++ .../components/card/InlineDashboard.test.tsx | 94 ++ .../src/components/card/InlineDashboard.tsx | 121 +++ .../src/components/card/WayfinderBlock.tsx | 58 ++ .../src/components/card/describeCard.ts | 45 + apps/desktop-tauri/src/main.tsx | 1 + apps/desktop-tauri/src/styles.css | 686 +------------ .../src/surfaces/TrayPanel.period.test.tsx | 25 +- .../src/surfaces/TrayPanel.test.tsx | 4 +- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 51 +- .../src/surfaces/menu-surface-layout.css | 110 +-- .../settings/providers/ProviderDetailPane.tsx | 4 + .../sections/UsageDetailsSection.test.tsx | 178 ++++ .../sections/UsageDetailsSection.tsx | 248 +++++ .../sections/charts/ChartsSection.test.tsx | 42 + .../sections/charts/ChartsSection.tsx | 14 +- .../src/surfaces/settings/settings-layout.css | 119 +++ apps/desktop-tauri/src/surfaces/tray-card.css | 571 +++++++++++ .../src/surfaces/trayCardStyles.test.ts | 150 +++ apps/desktop-tauri/src/test/styles.ts | 15 + 38 files changed, 2693 insertions(+), 3750 deletions(-) delete mode 100644 apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx delete mode 100644 apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx delete mode 100644 apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx delete mode 100644 apps/desktop-tauri/src/components/CodexAccountsMenu.tsx delete mode 100644 apps/desktop-tauri/src/components/GrokAccountsMenu.test.tsx delete mode 100644 apps/desktop-tauri/src/components/GrokAccountsMenu.tsx delete mode 100644 apps/desktop-tauri/src/components/MenuCardDetails.tsx delete mode 100644 apps/desktop-tauri/src/components/ProviderAccountsMenu.test.tsx delete mode 100644 apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx create mode 100644 apps/desktop-tauri/src/components/card/CardHeader.test.tsx create mode 100644 apps/desktop-tauri/src/components/card/CardHeader.tsx create mode 100644 apps/desktop-tauri/src/components/card/CardMetricRow.tsx create mode 100644 apps/desktop-tauri/src/components/card/DetailSections.tsx create mode 100644 apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx create mode 100644 apps/desktop-tauri/src/components/card/InlineDashboard.test.tsx create mode 100644 apps/desktop-tauri/src/components/card/InlineDashboard.tsx create mode 100644 apps/desktop-tauri/src/components/card/WayfinderBlock.tsx create mode 100644 apps/desktop-tauri/src/components/card/describeCard.ts create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx create mode 100644 apps/desktop-tauri/src/surfaces/tray-card.css create mode 100644 apps/desktop-tauri/src/surfaces/trayCardStyles.test.ts diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx deleted file mode 100644 index 4906e1524a..0000000000 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ClaudeAccount } from "../types/bridge"; - -const mocks = vi.hoisted(() => { - const listeners = new Map void>(); - return { - claudeAccountsList: vi.fn(), - claudeAccountAdd: vi.fn(), - claudeAccountReauthenticate: vi.fn(), - claudeAccountCancelLogin: vi.fn(), - claudeAccountSwitch: vi.fn(), - claudeReconciliationState: vi.fn(), - refreshProviders: vi.fn(), - listeners, - listen: vi.fn((event: string, callback: (event: { payload: unknown }) => void) => { - listeners.set(event, callback); - return Promise.resolve(() => listeners.delete(event)); - }), - }; -}); -vi.mock("../lib/tauri", () => mocks); -vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); -vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); -import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; - -const first: ClaudeAccount = { id: "first:org", email: "first@example.com", organization: "Personal", plan: "max", isActive: true, isSaved: true }; -const second: ClaudeAccount = { ...first, id: "second:org", email: "second@example.com", organization: "Work", isActive: false }; -const reconciliation = (generation: number, status: "pending" | "succeeded" | "failed", detail: string = status) => ({ - generation, status, detail, providerRefreshGeneration: null, -}); - -describe("ClaudeAccountsMenu", () => { - it("shows Refresh login only for the expired account and removes it after recovery", async () => { - mocks.claudeAccountsList.mockResolvedValue([first, { ...second, needsAuthentication: true, usageError: "Sign in again." }]); - mocks.claudeAccountReauthenticate.mockResolvedValue(undefined); - render(); - const refresh = await screen.findByRole("button", { name: `CodexAccountsReauthenticateButton: ${second.email}` }); - expect(screen.getAllByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toHaveLength(1); - mocks.claudeAccountsList.mockResolvedValue([first, second]); - await act(async () => fireEvent.click(refresh)); - expect(mocks.claudeAccountReauthenticate).toHaveBeenCalledWith(second.id); - expect(mocks.claudeAccountAdd).not.toHaveBeenCalled(); - expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); - expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); - }); - - it("does not suggest login for a temporary usage error", async () => { - mocks.claudeAccountsList.mockResolvedValue([{ ...first, usageError: "Will retry automatically.", needsAuthentication: false }]); - render(); - await screen.findByText(first.email); - expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); - }); - it("renders per-account usage and reloads it after background checks", async () => { - const usage = (used: number) => ({ fiveHour: { usedPercent: used, resetsAt: null }, sevenDay: null, updatedAt: "2026-10-03T12:00:00Z" }); - mocks.claudeAccountsList.mockResolvedValue([{ ...first, usage: usage(11) }, { ...second, usage: usage(63) }]); - render(); - await screen.findByText("89% PanelLeftSuffix"); - expect(screen.getByText("37% PanelLeftSuffix")).toBeInTheDocument(); - mocks.claudeAccountsList.mockResolvedValue([{ ...first, usage: usage(20) }, { ...second, usage: usage(70) }]); - await act(async () => mocks.listeners.get("claude-accounts-updated")?.({ payload: null })); - await screen.findByText("80% PanelLeftSuffix"); - expect(screen.getByText("30% PanelLeftSuffix")).toBeInTheDocument(); - expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); - }); - beforeEach(() => { - vi.clearAllMocks(); - mocks.listeners.clear(); - mocks.claudeAccountsList.mockResolvedValue([first, second]); - mocks.claudeReconciliationState.mockResolvedValue(null); - mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(1, "succeeded")); - mocks.refreshProviders.mockResolvedValue(undefined); - }); - - it("marks the current account and switches the selected saved account", async () => { - render(); - await screen.findByText(first.email); - const buttons = screen.getAllByText("CodexAccountsSwitchButton") as HTMLButtonElement[]; - expect(buttons[0].disabled).toBe(true); - expect(buttons[1].disabled).toBe(false); - await act(async () => fireEvent.click(buttons[1])); - expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); - expect(screen.getByRole("status").textContent).toBe("ClaudeAccountsSwitched"); - }); - - it("shows a single inactive saved account so the first login can be activated", async () => { - mocks.claudeAccountsList.mockResolvedValue([second]); - render(); - await screen.findByText(second.email); - const button = screen.getByText("CodexAccountsSwitchButton"); - expect(button).not.toBeDisabled(); - await act(async () => fireEvent.click(button)); - expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); - }); - - it("shows one active account and adds from the footer with cancellable sign-in", async () => { - mocks.claudeAccountsList.mockResolvedValue([first]); - let finishLogin: (() => void) | undefined; - mocks.claudeAccountAdd.mockImplementation(() => new Promise(resolve => { finishLogin = resolve; })); - mocks.claudeAccountCancelLogin.mockResolvedValue(undefined); - const { container } = render(); - const account = await screen.findByText(first.email); - expect(container.querySelector("details")?.open).toBe(true); - const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); - expect(account.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - await act(async () => fireEvent.click(add)); - expect(mocks.claudeAccountAdd).toHaveBeenCalledOnce(); - expect(add).toBeDisabled(); - expect(screen.getByText("ClaudeAccountsSigningIn")).toBeInTheDocument(); - await act(async () => fireEvent.click(screen.getByRole("button", { name: "ClaudeAccountsCancelLogin" }))); - expect(mocks.claudeAccountCancelLogin).toHaveBeenCalledOnce(); - mocks.claudeAccountsList.mockResolvedValue([first, second]); - await act(async () => finishLogin?.()); - expect(await screen.findByText(second.email)).toBeInTheDocument(); - expect(add).toBeEnabled(); - expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); - }); - - it("keeps the add action available when no accounts are saved and shows sign-in failures", async () => { - mocks.claudeAccountsList.mockResolvedValue([]); - mocks.claudeAccountAdd.mockRejectedValue("Login cancelled."); - render(); - const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); - await waitFor(() => expect(add).toBeEnabled()); - await act(async () => fireEvent.click(add)); - expect(screen.getByRole("alert")).toHaveTextContent("Login cancelled."); - expect(add).toBeEnabled(); - }); - - it("keeps the menu in activating and reconciling phases until the switch settles", async () => { - let resolveSwitch: ((value: ReturnType) => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { - resolveSwitch = resolve; - })); - render(); - await screen.findByText(first.email); - const details = () => document.querySelector("details[data-claude-account-phase]") as HTMLDetailsElement; - const row = screen.getByText(second.email).closest("li") as HTMLElement; - const button = within(row).getByRole("button"); - - await act(async () => fireEvent.click(button)); - expect(details().dataset.claudeAccountPhase).toBe("activating"); - expect(details()).toHaveAttribute("aria-busy", "true"); - - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "pending") }); - }); - expect(details().dataset.claudeAccountPhase).toBe("reconciling"); - - await act(async () => { - resolveSwitch?.(reconciliation(1, "pending")); - }); - // Settling is event-driven; the resolving switch promise alone stays in - // the reconciling phase until the backend emits the terminal event. - await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "succeeded") }); - }); - await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); - expect(details()).toHaveAttribute("aria-busy", "false"); - }); - - it("settles from a reconciled event fired with no local switch in flight", async () => { - render(); - await screen.findByText(first.email); - const details = () => document.querySelector("details[data-claude-account-phase]") as HTMLDetailsElement; - const row = screen.getByText(second.email).closest("li") as HTMLElement; - const button = within(row).getByRole("button"); - - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "pending") }); - }); - expect(details().dataset.claudeAccountPhase).toBe("reconciling"); - expect(details()).toHaveAttribute("aria-busy", "true"); - expect(button).toBeDisabled(); - - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "succeeded") }); - }); - expect(details().dataset.claudeAccountPhase).toBe("settled"); - expect(details()).toHaveAttribute("aria-busy", "false"); - expect(button).not.toBeDisabled(); - }); - - it("waits for the reconciled event before settling a local switch", async () => { - let resolveSwitch: ((value: ReturnType) => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { - resolveSwitch = resolve; - })); - render(); - await screen.findByText(first.email); - const details = () => document.querySelector("details[data-claude-account-phase]") as HTMLDetailsElement; - - const row = screen.getByText(second.email).closest("li") as HTMLElement; - const button = within(row).getByRole("button"); - await act(async () => fireEvent.click(button)); - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "pending") }); - }); - await act(async () => { - resolveSwitch?.(reconciliation(3, "pending")); - }); - // The switch promise resolving is not enough: settling is event-driven. - await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "succeeded") }); - }); - await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); - }); - - it("surfaces a late failure for the matching pending generation", async () => { - mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(8, "pending")); - render(); - await screen.findByText(second.email); - const row = screen.getByText(second.email).closest("li") as HTMLElement; - await act(async () => fireEvent.click(within(row).getByRole("button"))); - await waitFor(() => { - expect(document.querySelector("details")?.dataset.claudeAccountPhase).toBe("reconciling"); - }); - - await act(async () => { - mocks.listeners.get("claude-reconciliation-changed")?.({ - payload: reconciliation(8, "failed", "late refresh failed"), - }); - }); - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain("late refresh failed"); - }); - expect(screen.queryByText("ClaudeAccountsSwitched")).toBeNull(); - }); - - it("uses stable opaque account labels and redacts tooltips when hideEmail is enabled", async () => { - mocks.claudeAccountsList.mockResolvedValue([first, { ...second, organization: `${second.email}'s Organization` }]); - const { container } = render(); - await screen.findByText("Account 1"); - const labels = container.querySelectorAll(".codex-menu-accounts__email"); - expect(labels[0].firstChild?.textContent).toBe("Account 1"); - expect(labels[0].getAttribute("title")).toBe("Account 1"); - expect(labels[1].firstChild?.textContent).toBe("Account 2"); - expect(labels[1].getAttribute("title")).toBe("Account 2"); - expect(container.textContent).not.toContain(first.email); - expect(container.innerHTML).not.toContain(second.email); - expect(container.textContent).not.toContain("Personal"); - expect(container.textContent).not.toContain("Organization"); - }); - - it("keeps opaque labels stable when the source reorders accounts", async () => { - const { container } = render(); - await screen.findByText("Account 1"); - mocks.claudeAccountsList.mockResolvedValue([second, first]); - await act(async () => window.dispatchEvent(new Event("focus"))); - await waitFor(() => { - const labels = container.querySelectorAll(".codex-menu-accounts__email"); - expect(labels[0].firstChild?.textContent).toBe("Account 2"); - expect(labels[1].firstChild?.textContent).toBe("Account 1"); - }); - }); - - it("shows switch failures and leaves the current account marked active", async () => { - mocks.claudeAccountSwitch.mockRejectedValue("Close Claude Code first."); - render(); - await screen.findByText(first.email); - const row = screen.getByText(second.email).closest("li") as HTMLElement; - await act(async () => fireEvent.click(within(row).getByRole("button"))); - expect(screen.getByRole("alert").textContent).toContain("Close Claude Code first."); - expect(screen.queryByRole("status")).toBeNull(); - expect(mocks.refreshProviders).not.toHaveBeenCalled(); - }); - - it("keeps account loading failures discoverable and retries when the window gains focus", async () => { - mocks.claudeAccountsList.mockRejectedValueOnce("Account storage unavailable."); - const onLayoutChange = vi.fn(); - render(); - await screen.findByText("Account storage unavailable."); - expect(screen.getByText("ClaudeAccountsTitle")).toBeInTheDocument(); - await act(async () => window.dispatchEvent(new Event("focus"))); - expect(await screen.findByText(second.email)).toBeInTheDocument(); - expect(screen.queryByText("Account storage unavailable.")).toBeNull(); - expect(onLayoutChange).toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx deleted file mode 100644 index 646f8924a6..0000000000 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; -import type { ClaudeAccount } from "../types/bridge"; -import { claudeAccountsList, claudeAccountAdd, claudeAccountReauthenticate, claudeAccountCancelLogin, claudeAccountSwitch } from "../lib/tauri"; -import { useLocale } from "../hooks/useLocale"; -import ClaudeAccountUsage from "./ClaudeAccountUsage"; -import ProviderAccountsMenu from "./ProviderAccountsMenu"; -import { - localClaudeReconciliationOutcome, - useClaudeReconciliation, -} from "../hooks/useClaudeReconciliation"; -import { - buildClaudeAccountOrdinals, - buildPrivateClaudeAccountLabel, -} from "./claudeAccountDisplay"; - -export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange, showAsUsed = false, resetTimeRelative = true }: { - hideEmail: boolean; - onLayoutChange?: () => void; - showAsUsed?: boolean; - resetTimeRelative?: boolean; -}) { - const { t } = useLocale(); - const [accounts, setAccounts] = useState([]); - const [error, setError] = useState(null); - const [switched, setSwitched] = useState(false); - const [activating, setActivating] = useState(false); - const [signingIn, setSigningIn] = useState(false); - const [loading, setLoading] = useState(true); - const [operationGeneration, setOperationGeneration] = useState(null); - const { snapshot, accept, reconciling } = useClaudeReconciliation(); - const mounted = useRef(false); - const load = useCallback(async () => { - try { - const next = await claudeAccountsList(); - if (mounted.current) { - setAccounts(next); - setError(null); - } - } finally { - if (mounted.current) setLoading(false); - } - }, []); - useEffect(() => { - mounted.current = true; - const reload = () => { - void load().catch(e => { if (mounted.current) setError(String(e)); }); - }; - reload(); - window.addEventListener("focus", reload); - const unlisten = listen("claude-accounts-updated", reload); - return () => { - mounted.current = false; - window.removeEventListener("focus", reload); - void unlisten.then(fn => fn()).catch(() => {}); - }; - }, [load]); - useEffect(() => { - const outcome = localClaudeReconciliationOutcome(snapshot, operationGeneration); - if (!outcome) return; - if (outcome.status === "failed") { - setSwitched(false); - setError(outcome.detail); - } else { - setSwitched(true); - setError(null); - } - }, [operationGeneration, snapshot]); - const phase = reconciling - ? "reconciling" - : activating - ? "activating" - : snapshot - ? "settled" - : "idle"; - useEffect(() => { - onLayoutChange?.(); - }, [accounts, error, phase, switched, signingIn, onLayoutChange]); - - const accountOrdinals = buildClaudeAccountOrdinals(accounts); - - const switchAccount = async (id: string) => { - setActivating(true); - setOperationGeneration(null); - setError(null); - setSwitched(false); - try { - const result = await claudeAccountSwitch(id); - setOperationGeneration(result.generation); - accept(result); - await load(); - } catch (e) { - if (mounted.current) { - setError(String(e)); - } - } finally { - if (mounted.current) setActivating(false); - } - }; - - const addAccount = async (id?: string) => { - setSigningIn(true); - setError(null); - setSwitched(false); - try { - await (id ? claudeAccountReauthenticate(id) : claudeAccountAdd()); - await load(); - } catch (e) { - if (mounted.current) setError(String(e)); - } finally { - if (mounted.current) setSigningIn(false); - } - }; - const busy = loading || activating || reconciling || signingIn; - return ( - - - {signingIn && } - } - > - {error &&
{error}
} - {switched &&

{t("ClaudeAccountsSwitched")}

} - {signingIn &&

{t("ClaudeAccountsSigningIn")}

} -
    - {accounts.map(account => { - const privateLabel = buildPrivateClaudeAccountLabel( - account, - accountOrdinals[account.id], - hideEmail, - t("Account"), - ); - return ( -
  • -
    -
    - - {privateLabel.label} - {account.isActive && {t("TokenAccountActive")}} - - {!hideEmail && account.organization && !account.organization.includes(account.email) && ( - {account.organization} - )} - -
    -
    - {account.needsAuthentication && ( - - )} - -
    -
    -
  • - ); - })} -
-
- ); -} diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx deleted file mode 100644 index d7df1ae8c6..0000000000 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx +++ /dev/null @@ -1,419 +0,0 @@ -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(), - codexAccountAdd: vi.fn(), - codexAccountReauthenticate: vi.fn(), - refreshProviders: vi.fn(), - getLocaleStrings: vi.fn(), -})); - -const eventMocks = vi.hoisted(() => ({ - listen: vi.fn((_event: string, _callback: () => void) => 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, - resetAt: string | null = null, -): CodexAccountUsageSnapshot { - return { - email: "user@example.com", - providerAccountId: null, - plan: "free", - allowed: true, - limitReached: false, - primaryWindow: { usedPercent, resetAt, limitWindowSeconds: 18_000 }, - 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, - resetTimeRelative = true, - showAsUsed = false, -) { - tauriMocks.getCodexAccountsState.mockResolvedValue(state); - tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); - return render( - - - , - ); -} - -describe("CodexAccountsMenu", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("offers login actions even for a single account", async () => { - renderMenu(false, { - accounts: [account("1", { source: "ambient" })], - accountOrdinals: { "1": 1 }, snapshots: {}, - needsAuthentication: { "1": true }, - }); - expect(await screen.findByRole("button", { name: "CodexAccountsAddButton" })).toBeDefined(); - const refresh = await screen.findByRole("button", { name: "CodexAccountsReauthenticateButton: user-1@example.com" }); - await waitFor(() => expect((refresh as HTMLButtonElement).disabled).toBe(false)); - await act(async () => { refresh.click(); }); - expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledOnce(); - }); - - it("expands the account list by default and places add account below the rows", async () => { - const { container } = renderMenu(false, { - accounts: [account("one"), account("two")], - accountOrdinals: { one: 1, two: 2 }, snapshots: {}, - }); - const row = await screen.findByText("user-two@example.com"); - expect(container.querySelector("details")?.open).toBe(true); - const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); - expect(row.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - - it("offers account sign-in with no saved accounts and reports a login failure", async () => { - renderMenu(false, { accounts: [], accountOrdinals: {}, snapshots: {} }); - const add = await screen.findByRole("button", { name: "CodexAccountsAddButton" }); - await waitFor(() => expect((add as HTMLButtonElement).disabled).toBe(false)); - tauriMocks.codexAccountAdd.mockRejectedValueOnce(new Error("Login cancelled")); - await act(async () => { add.click(); }); - expect(tauriMocks.codexAccountAdd).toHaveBeenCalledOnce(); - expect(await screen.findByRole("alert")).toHaveTextContent("Login cancelled"); - }); - - it("reauthenticates a single saved account without switching it and reports cancellation", async () => { - renderMenu(false, { accounts: [account("expired")], accountOrdinals: { expired: 1 }, snapshots: {}, needsAuthentication: { expired: true } }); - const refresh = await screen.findByRole("button", { - name: "CodexAccountsReauthenticateButton: user-expired@example.com", - }); - await waitFor(() => expect(refresh).toBeEnabled()); - tauriMocks.codexAccountReauthenticate.mockRejectedValueOnce(new Error("Account setup cancelled.")); - await act(async () => { refresh.click(); }); - expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledWith("expired"); - expect(tauriMocks.codexAccountSwitch).not.toHaveBeenCalled(); - expect(await screen.findByRole("alert")).toHaveTextContent("Account setup cancelled."); - expect(refresh).toBeEnabled(); - }); - - it("shows both quota windows for each account without expanding a disclosure", async () => { - const both = { ...snapshot(30), secondaryWindow: { usedPercent: 65, resetAt: null, limitWindowSeconds: 604800 } }; - const { container } = renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, snapshots: { "1": both, "2": snapshot(70) }, - }); - await screen.findByText("user-1@example.com"); - expect(container.querySelector("details")?.open).toBe(true); - expect(screen.getByText("35% PanelLeftSuffix")).toBeDefined(); - expect(screen.getByText("7d")).toBeDefined(); - expect(screen.getByText("30% PanelLeftSuffix")).toBeDefined(); - expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); - }); - - it("keeps login actions disabled while account updates arrive during sign-in", async () => { - let finish!: () => void; - tauriMocks.codexAccountAdd.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); - renderMenu(false, { accounts: [], accountOrdinals: {}, snapshots: {} }); - const add = await screen.findByRole("button", { name: "CodexAccountsAddButton" }) as HTMLButtonElement; - await waitFor(() => expect(add.disabled).toBe(false)); - await act(async () => { add.click(); }); - const callback = eventMocks.listen.mock.calls.find(([event]) => event === "codex-accounts-updated")?.[1] as (() => void) | undefined; - await act(async () => { callback?.(); }); - expect(add.disabled).toBe(true); - await act(async () => { finish(); }); - await waitFor(() => expect(add.disabled).toBe(false)); - }); - - 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"), - ], - accountOrdinals: { "1": 1, "2": 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("70%"); - expect((fills[1] as HTMLElement).style.width).toBe("30%"); - }); - - it("renders both five-hour and weekly usage bars when both windows exist", async () => { - const both: CodexAccountUsageSnapshot = { - email: "both@example.com", - providerAccountId: null, - plan: "plus", - allowed: true, - limitReached: false, - primaryWindow: { - usedPercent: 14, - resetAt: "2030-01-02T03:04:00Z", - limitWindowSeconds: 18_000, - }, - secondaryWindow: { - usedPercent: 93, - resetAt: "2030-01-08T03:04:00Z", - limitWindowSeconds: 604_800, - }, - credits: null, - updatedAt: "2024-01-01T00:00:00Z", - }; - const { container } = renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: { "1": both }, - }, false); - await screen.findByText("user-1@example.com"); - expect(screen.getByText("86% PanelLeftSuffix")).toBeInTheDocument(); - expect(screen.getByText("7% PanelLeftSuffix")).toBeInTheDocument(); - const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill"); - expect(fills.length).toBe(2); - expect((fills[0] as HTMLElement).style.width).toBe("86%"); - expect((fills[0] as HTMLElement).dataset.level).toBeUndefined(); - expect((fills[1] as HTMLElement).style.width).toBe("7%"); - expect((fills[1] as HTMLElement).dataset.level).toBe("critical"); - expect(screen.getByText("5h")).toBeInTheDocument(); - expect(screen.getByText("7d")).toBeInTheDocument(); - for (const resetAt of [both.primaryWindow!.resetAt!, both.secondaryWindow!.resetAt!]) { - const formatted = new Intl.DateTimeFormat(undefined, { - month: "short", day: "numeric", hour: "numeric", minute: "2-digit", - }).format(new Date(resetAt)); - expect(screen.getByText(`MetricResetsIn ${formatted}`)).toBeInTheDocument(); - } - }); - - it("renders a usage bar from a weekly-only snapshot (primaryWindow: null)", async () => { - const weeklyOnly: CodexAccountUsageSnapshot = { - email: "weekly@example.com", - providerAccountId: null, - plan: "pro", - allowed: true, - limitReached: false, - primaryWindow: null, - secondaryWindow: { - usedPercent: 42, - resetAt: null, - limitWindowSeconds: 604800, - }, - credits: null, - updatedAt: "2024-01-01T00:00:00Z", - }; - const { container } = renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: { "1": weeklyOnly }, - }); - await screen.findByText("user-1@example.com"); - - const fills = container.querySelectorAll( - ".codex-menu-accounts__bar-fill", - ); - expect(fills.length).toBe(1); - expect((fills[0] as HTMLElement).style.width).toBe("58%"); - }); - - it("shows the five-hour usage and local reset time for each account", async () => { - const resetAt = "2030-01-02T03:04:00Z"; - const expectedReset = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(resetAt)); - - renderMenu( - false, - { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: { "1": snapshot(30, resetAt), "2": snapshot(70, resetAt) }, - }, - false, - ); - - await screen.findByText("user-1@example.com"); - expect(screen.getAllByText("5h")).toHaveLength(2); - expect(screen.getByText("70% PanelLeftSuffix")).toBeDefined(); - expect(screen.getAllByText(`MetricResetsIn ${expectedReset}`)).toHaveLength(2); - }); - - it("shows remaining capacity alongside used capacity when the used setting is enabled", async () => { - renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, snapshots: { "1": snapshot(30) }, - }, true, true); - await screen.findByText("70% PanelLeftSuffix"); - expect(screen.getByText("30% PanelUsedSuffix")).toBeDefined(); - expect(screen.getByText("CodexAccountsUsageUnavailable")).toBeDefined(); - }); - - it("distinguishes exhausted and full quotas from unknown usage", async () => { - const { container } = renderMenu(false, { - accounts: [account("1"), account("2"), account("3")], - accountOrdinals: { "1": 1, "2": 2, "3": 3 }, - snapshots: { "1": snapshot(100), "2": snapshot(0), "3": snapshot(Number.NaN) }, - }); - await screen.findByText("0% PanelLeftSuffix"); - expect(screen.getByText("100% PanelLeftSuffix")).toBeDefined(); - expect(screen.getByText("CodexAccountsUsageUnavailable")).toBeDefined(); - const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill"); - expect(fills).toHaveLength(2); - expect((fills[0] as HTMLElement).style.width).toBe("0%"); - expect((fills[1] as HTMLElement).style.width).toBe("100%"); - }); - - it("switches an account and kicks a provider refresh", async () => { - renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: {}, - }); - await screen.findByText("user-1@example.com"); - - tauriMocks.codexAccountSwitch.mockResolvedValue({}); - tauriMocks.getCodexAccountsState.mockResolvedValue({ - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 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); - expect(screen.getByText("CodexAccountsSwitchedHint")).toBeInTheDocument(); - }); - it("uses an opaque ordinal for every account while hideEmail is on", async () => { - const { container: hidden } = renderMenu(true, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: {}, - }); - await waitFor(() => { - expect( - hidden.querySelectorAll(".codex-menu-accounts__email").length, - ).toBe(2); - }); - // Upstream 0.60.5 #3702 (141ecf642): every account is redacted, not only - // ambient ones — managed accounts get the generic label too. - 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 managedLabel = hidden.querySelectorAll( - ".codex-menu-accounts__email", - )[1] as HTMLElement; - expect(managedLabel.getAttribute("title")).toBe("Account 2"); - expect(managedLabel.textContent).toBe("Account 2"); - expect(managedLabel.textContent).not.toContain("user-2@example.com"); - const switches = hidden.querySelectorAll( - ".codex-menu-accounts__switch", - ) as NodeListOf; - expect(switches[0].disabled).toBe(true); - expect(switches[1].disabled).toBe(false); - - const { container: visible } = renderMenu(false, { - accounts: [account("1", { source: "ambient" }), account("2")], - accountOrdinals: { "1": 1, "2": 2 }, - snapshots: {}, - }); - await waitFor(() => { - expect( - visible.querySelectorAll(".codex-menu-accounts__email").length, - ).toBe(2); - }); - const rawEmail = visible.querySelectorAll( - ".codex-menu-accounts__email", - )[1] as HTMLElement; - expect(rawEmail.getAttribute("title")).toBe("user-2@example.com"); - }); - - it("uses canonical opaque ordinals for every account supplied by the bridge", async () => { - const first = account("uuid-b", { - emailHint: "alice@example.com", - nickname: "team@example.com", - source: "ambient", - }); - const second = account("uuid-a", { - emailHint: "bob@example.com", - nickname: "Private workspace", - }); - - const { container } = renderMenu(true, { - accounts: [first, second], - accountOrdinals: { "uuid-b": 2, "uuid-a": 1 }, - snapshots: {}, - }); - // Upstream 0.60.5 #3702 (141ecf642): every account is redacted, not only - // ambient ones — managed accounts get the generic label too. - await screen.findByText("Account 2"); - await screen.findByText("Account 1"); - const labels = container.querySelectorAll(".codex-menu-accounts__email"); - expect(labels[0].firstChild?.textContent).toBe("Account 2"); - expect(labels[1].textContent).toBe("Account 1"); - expect(labels[0].firstChild?.textContent).not.toContain("@"); - expect(labels[1].textContent).not.toContain("bob@example.com"); - expect(labels[1].textContent).not.toContain("Private workspace"); - }); -}); - diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx deleted file mode 100644 index 84ab623a3b..0000000000 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx +++ /dev/null @@ -1,301 +0,0 @@ -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 { useFormattedResetTime } from "../hooks/useFormattedResetTime"; -import ProviderAccountsMenu from "./ProviderAccountsMenu"; -import { buildCodexAccountSurfaceLabels } from "./codexAccountDisplay"; -import { - codexAccountAdd, - codexAccountReauthenticate, - codexAccountSwitch, - getCodexAccountsState, - refreshProviders, -} from "../lib/tauri"; - -/** Visible account overview and login actions for the Codex tray card. */ -export default function CodexAccountsMenu({ - hideEmail, - resetTimeRelative, - showAsUsed = false, - needsAuthentication = false, - onLayoutChange, -}: { - hideEmail: boolean; - resetTimeRelative: boolean; - showAsUsed?: boolean; - needsAuthentication?: boolean; - onLayoutChange?: () => void; -}) { - const { t } = useLocale(); - const [accounts, setAccounts] = useState([]); - const [snapshots, setSnapshots] = useState< - Record - >({}); - const [displayNames, setDisplayNames] = useState>({}); - const [accountOrdinals, setAccountOrdinals] = useState>({}); - const [accountNeedsAuthentication, setAccountNeedsAuthentication] = useState>({}); - const [loading, setLoading] = useState(true); - const [pending, setPending] = useState(false); - const [signingIn, setSigningIn] = useState(false); - const busy = loading || pending; - const [error, setError] = useState(null); - const [switched, setSwitched] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const next: CodexAccountsStateBridge = await getCodexAccountsState(); - setAccounts(next.accounts); - setDisplayNames(next.displayNames ?? {}); - setAccountOrdinals(next.accountOrdinals); - setSnapshots(next.snapshots); - setAccountNeedsAuthentication(next.needsAuthentication ?? {}); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - onLayoutChange?.(); - }, [accounts, snapshots, error, pending, switched, onLayoutChange]); - - useEffect(() => { - let cancelled = false; - const unlistenPromise = listen("codex-accounts-updated", () => { - if (!cancelled) void load(); - }); - return () => { - cancelled = true; - void unlistenPromise.then((fn) => fn()); - }; - }, [load]); - - const run = async (action: () => Promise, login = false) => { - setSwitched(false); - setSigningIn(login); - setPending(true); - setError(null); - try { - await action(); - await load(); - void refreshProviders().catch(() => {}); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setPending(false); - setSigningIn(false); - } - }; - - const handleSwitch = (id: string) => - run(async () => { - await codexAccountSwitch(id); - setSwitched(true); - }); - const ambient = accounts.find((account) => account.source === "ambient"); - - const accountDisplayNames = buildCodexAccountSurfaceLabels( - accounts, - displayNames, - accountOrdinals, - hideEmail, - t("Account"), - ); - - return ( - - - } - > - - {pending && ( -
- {t(signingIn ? "CodexAccountsSigningIn" : "TrayLoading")} -
- )} - {switched &&

{t("CodexAccountsSwitchedHint")}

} - {error && ( -
- {error} -
- )} - {accounts.length > 0 && ( -
    - {accounts.map((account) => { - const label = accountDisplayNames[account.id]; - return ( - run(() => codexAccountReauthenticate(id), true)} - /> - ); - })} -
- )} -
- ); -} - -function CodexAccountRow({ - account, - snapshot, - displayName, - tooltip, - showAsUsed, - resetTimeRelative, - busy, - needsAuthentication, - onSwitch, - onReauthenticate, -}: { - account: CodexAccount; - snapshot: CodexAccountUsageSnapshot | undefined; - displayName: string; - tooltip: string; - showAsUsed: boolean; - resetTimeRelative: boolean; - busy: boolean; - needsAuthentication: boolean; - onSwitch: (id: string) => Promise; - onReauthenticate: (id: string) => Promise; -}) { - const { t } = useLocale(); - const isAmbient = account.source === "ambient"; - - return ( -
  • -
    -
    - - {displayName} - {isAmbient && ( - - {t("CodexAccountsSourceAmbient")} - - )} - - {snapshot?.plan && ( - {snapshot.plan} - )} - {snapshot?.primaryWindow && ( - - )} - {snapshot?.secondaryWindow && ( - - )} - {!snapshot?.primaryWindow && !snapshot?.secondaryWindow && ( - {t("CodexAccountsUsageUnavailable")} - )} -
    -
    - {needsAuthentication && ( - - )} - -
    -
    -
  • - ); -} - -function AccountWindow({ - window, - resetTimeRelative, - showAsUsed, -}: { - showAsUsed: boolean; - window: NonNullable; - resetTimeRelative: boolean; -}) { - const { t } = useLocale(); - const known = Number.isFinite(window.usedPercent); - const used = Math.max(0, Math.min(100, Math.round(window.usedPercent))); - const remaining = 100 - used; - const resetText = useFormattedResetTime(window.resetAt, null, resetTimeRelative); - const resetLabel = resetText && ( - resetTimeRelative ? resetText : `${t("MetricResetsIn")} ${resetText}` - ); - return ( -
    - - {formatWindowLabel(window.limitWindowSeconds) ?? t("DetailWindowPrimary")} - - {known ? `${remaining}% ${t("PanelLeftSuffix")}` : t("CodexAccountsUsageUnavailable")} - - {known && showAsUsed && {used}% {t("PanelUsedSuffix")}} - {resetLabel && {resetLabel}} - - - {known && ( - = 100 ? "exhausted" : used >= 90 ? "critical" : undefined} - style={{ width: `${remaining}%` }} - /> - )} - -
    - ); -} -function formatWindowLabel( - limitWindowSeconds: number | null | undefined, -): string | null { - if (!limitWindowSeconds || limitWindowSeconds <= 0) return null; - if (limitWindowSeconds % 86_400 === 0) { - return `${limitWindowSeconds / 86_400}d`; - } - if (limitWindowSeconds % 3_600 === 0) { - return `${limitWindowSeconds / 3_600}h`; - } - return null; -} diff --git a/apps/desktop-tauri/src/components/GrokAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/GrokAccountsMenu.test.tsx deleted file mode 100644 index a038393de8..0000000000 --- a/apps/desktop-tauri/src/components/GrokAccountsMenu.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { GrokAccount, ProviderUsageSnapshot } from "../types/bridge"; - -const mocks = vi.hoisted(() => ({ - grokAccountsList: vi.fn(), - grokAccountSwitch: vi.fn(), - grokAccountFetch: vi.fn(), - grokAccountReauthenticate: vi.fn(), - grokAccountCancelLogin: vi.fn(), - listeners: new Map void>(), -})); -vi.mock("../lib/tauri", () => mocks); -vi.mock("@tauri-apps/api/event", () => ({ - listen: (event: string, callback: (event: { payload: unknown }) => void) => { - mocks.listeners.set(event, callback); - return Promise.resolve(() => mocks.listeners.delete(event)); - }, -})); -vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); -import GrokAccountsMenu from "./GrokAccountsMenu"; - -const first: GrokAccount = { - id: "user-one", - email: "one@example.com", - organization: null, - plan: "SuperGrok", - isActive: true, - isSaved: true, -}; -const second: GrokAccount = { - ...first, - id: "user-two", - email: "two@example.com", - isActive: false, -}; - -function cardSnapshot(): Pick { - return { - accountEmail: first.email, - accountOrganization: null, - planName: "SuperGrok", - error: null, - primary: { - usedPercent: 47, - remainingPercent: 53, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null, - isExhausted: false, - reservePercent: null, - reserveDescription: null, - }, - }; -} - -describe("GrokAccountsMenu", () => { - it("allows switching a saved account while its usage request is pending", async () => { - mocks.grokAccountFetch.mockImplementation(() => new Promise(() => {})); - mocks.grokAccountSwitch.mockResolvedValue(undefined); - render(); - await screen.findByText(second.email); - const buttons = screen.getAllByRole("button", { name: "CodexAccountsSwitchButton" }); - expect(buttons[1]).toBeEnabled(); - fireEvent.click(buttons[1]); - await waitFor(() => expect(mocks.grokAccountSwitch).toHaveBeenCalledWith(second.id)); - }); - - it("shows the other account's reading while one account still loads", async () => { - mocks.grokAccountsList.mockResolvedValue([first, second]); - mocks.grokAccountFetch.mockImplementation((id: string) => id === first.id - ? new Promise(() => {}) - : Promise.resolve({ status: "ready", usageAvailable: true, usedPercent: 23, resetsAt: null, windowMinutes: 10080, plan: null })); - render(); - expect(await screen.findByText("23% PanelUsedSuffix")).toBeInTheDocument(); - expect(screen.getByText("GrokUsageLoading")).toBeInTheDocument(); - expect(screen.getByText("GrokUsageResetUnavailable")).toBeInTheDocument(); - }); - - it("reauthenticates the selected expired account without calling Switch", async () => { - mocks.grokAccountFetch.mockImplementation(async (id: string) => ({ - status: id === second.id ? "signInRequired" : "ready", - usageAvailable: id !== second.id, usedPercent: id === second.id ? null : 0, - resetsAt: null, windowMinutes: null, plan: null, - })); - mocks.grokAccountReauthenticate.mockResolvedValue(undefined); - render(); - fireEvent.click(await screen.findByText("GrokAccountsSignInAgain")); - await waitFor(() => expect(mocks.grokAccountReauthenticate).toHaveBeenCalledWith(second.id)); - expect(mocks.grokAccountSwitch).not.toHaveBeenCalled(); - expect(await screen.findByText("0% PanelUsedSuffix")).toBeInTheDocument(); - expect(screen.queryByText("GrokAccountsSwitched")).toBeNull(); - }); - - beforeEach(() => { - vi.resetAllMocks(); - mocks.listeners.clear(); - mocks.grokAccountsList.mockResolvedValue([first, second]); - mocks.grokAccountFetch.mockImplementation(async (id: string) => ({ - usageAvailable: true, - usedPercent: id === first.id ? 38 : 100, - plan: "SuperGrok", - windowMinutes: 10080, - resetsAt: "2026-09-25T05:55:45Z", - })); - }); - - it("shows Codex-style usage bars for each Grok account", async () => { - const { container } = render( - , - ); - await screen.findByText(first.email); - expect(screen.getAllByText("7d").length).toBeGreaterThan(0); - expect(screen.getByText("38% PanelUsedSuffix")).toBeInTheDocument(); - expect(screen.getByText("100% PanelUsedSuffix")).toBeInTheDocument(); - const bars = container.querySelectorAll(".codex-menu-accounts__bar-fill"); - expect(bars).toHaveLength(2); - expect((bars[0] as HTMLElement).style.width).toBe("38%"); - expect((bars[1] as HTMLElement).style.width).toBe("100%"); - }); - - it("omits percentage and bar when usage is unavailable", async () => { - mocks.grokAccountFetch.mockResolvedValue({ - usageAvailable: false, - usedPercent: null, - plan: "SuperGrok", - windowMinutes: 10080, - resetsAt: null, - }); - const { container } = render( - , - ); - - await screen.findByText(first.email); - expect(screen.queryByText(/PanelUsedSuffix/)).toBeNull(); - expect(container.querySelectorAll(".codex-menu-accounts__bar-fill")).toHaveLength(0); - }); - - it("refreshes both account rows on Grok provider updates without a button click", async () => { - const { unmount } = render(); - await screen.findByText("38% PanelUsedSuffix"); - expect(mocks.grokAccountFetch).toHaveBeenCalledTimes(2); - - await act(async () => { - mocks.listeners.get("provider-updated")?.({ payload: { providerId: "codex" } }); - }); - expect(mocks.grokAccountFetch).toHaveBeenCalledTimes(2); - - mocks.grokAccountFetch.mockImplementation(async (id: string) => ({ - status: "ready", usageAvailable: true, - usedPercent: id === first.id ? 42 : 56, - plan: "SuperGrok", windowMinutes: 10080, - resetsAt: "2026-10-05T05:55:45Z", - })); - await act(async () => { - mocks.listeners.get("provider-updated")?.({ payload: { providerId: "grok" } }); - }); - expect(await screen.findByText("42% PanelUsedSuffix")).toBeInTheDocument(); - expect(screen.getByText("56% PanelUsedSuffix")).toBeInTheDocument(); - expect(mocks.grokAccountFetch).toHaveBeenCalledTimes(4); - - unmount(); - await act(async () => {}); - expect(mocks.listeners.has("provider-updated")).toBe(false); - expect(mocks.listeners.has("grok-accounts-updated")).toBe(false); - }); - - it("uses matching card usage only for the active account when a fetch fails", async () => { - mocks.grokAccountFetch.mockRejectedValue(new Error("Unavailable")); - const { container } = render( - , - ); - await screen.findByText(first.email); - expect(screen.getByText("47% PanelUsedSuffix")).toBeInTheDocument(); - expect(container.querySelectorAll(".codex-menu-accounts__bar-fill")).toHaveLength(1); - }); - - it("preserves account-specific usage when a matching card has a different value", async () => { - render(); - await screen.findByText(first.email); - expect(screen.getByText("38% PanelUsedSuffix")).toBeInTheDocument(); - expect(screen.queryByText("47% PanelUsedSuffix")).toBeNull(); - }); - - it.each(["different account", "unknown identity", "different organization", "informational", "error"])( - "does not borrow %s card usage", async (scenario) => { - mocks.grokAccountFetch.mockRejectedValue(new Error("Unavailable")); - const card = cardSnapshot(); - if (scenario === "different account") card.accountEmail = second.email; - if (scenario === "unknown identity") card.accountEmail = null; - if (scenario === "different organization") card.accountOrganization = "other-team"; - if (scenario === "informational") card.primary.isInformational = true; - if (scenario === "error") card.error = "Expired credentials"; - const { container } = render( - , - ); - await screen.findByText(first.email); - expect(screen.queryByText(/PanelUsedSuffix/)).toBeNull(); - expect(container.querySelectorAll(".codex-menu-accounts__bar-fill")).toHaveLength(0); - }, - ); -}); diff --git a/apps/desktop-tauri/src/components/GrokAccountsMenu.tsx b/apps/desktop-tauri/src/components/GrokAccountsMenu.tsx deleted file mode 100644 index 64310ef0c5..0000000000 --- a/apps/desktop-tauri/src/components/GrokAccountsMenu.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; -import type { GrokAccount, GrokAccountUsage, ProviderUsageSnapshot } from "../types/bridge"; -import { grokAccountSwitch, grokAccountReauthenticate, grokAccountCancelLogin } from "../lib/tauri"; -import { useLocale } from "../hooks/useLocale"; -import GrokAccountUsageDetails from "./GrokAccountUsageDetails"; -import { useGrokAccounts } from "../hooks/useGrokAccounts"; -import { maskEmail } from "./MenuCard"; - -export default function GrokAccountsMenu({ - hideEmail, - resetTimeRelative, - onLayoutChange, - cardSnapshot, - renderHeader, -}: { - hideEmail: boolean; - resetTimeRelative: boolean; - onLayoutChange?: () => void; - cardSnapshot?: GrokAccountsCardSnapshot; - renderHeader?: (refreshAction?: ReactNode) => ReactNode; -}) { - const { t } = useLocale(); - const [switched, setSwitched] = useState(false); - const [loggingIn, setLoggingIn] = useState(false); - const [reauthenticated, setReauthenticated] = useState(false); - const { accounts, usage, busy, error, run, reload, reportError } = useGrokAccounts({ reloadOnFocus: true }); - useEffect(() => { - onLayoutChange?.(); - }, [accounts.length, usage, cardSnapshot, error, switched, loggingIn, reauthenticated, onLayoutChange]); - - const signInAgain = async (id: string) => { - setLoggingIn(true); - setSwitched(false); - setReauthenticated(false); - await run(() => grokAccountReauthenticate(id), () => setReauthenticated(true), () => setLoggingIn(false)); - }; - - const switchAccount = async (id: string) => { - setSwitched(false); - setReauthenticated(false); - await run(() => grokAccountSwitch(id), () => setSwitched(true)); - }; - - if (accounts.length === 0 && !error) return renderHeader?.() ?? null; - const refreshAction = ( - - ); - return ( - <> - {renderHeader ? renderHeader(refreshAction) : refreshAction} -
    - - {t("GrokAccountsTitle")} - {accounts.length} - - {error && ( -
    - {error} -
    - )} - {switched &&

    {t("GrokAccountsSwitched")}

    } - {reauthenticated &&

    {t("GrokAccountsReauthenticated")}

    } -

    {t("GrokAccountsSourceHint")}

    - {loggingIn &&

    {t("GrokAccountsSigningIn")}

    } - {loggingIn && } -
      - {accounts.map((account) => ( - - ))} -
    -
    - - ); -} - -function accountUsage( - account: GrokAccount, - snapshot: GrokAccountUsage | undefined, - card: GrokAccountsCardSnapshot | undefined, -): GrokAccountUsage | undefined { - if (snapshot?.usageAvailable && snapshot.usedPercent != null) return snapshot; - // The card may still belong to the previous CLI or browser account after a - // switch. Unknown identity and informational windows cannot prove usage. - const email = card?.accountEmail?.trim().toLowerCase(); - if (!account.isActive || !card || card.error || !email - || email !== account.email.trim().toLowerCase() - || (card.accountOrganization ?? null) !== account.organization - || card.primary.isInformational - || !Number.isFinite(card.primary.usedPercent)) return snapshot; - return { - status: "ready", - usageAvailable: true, - usedPercent: card.primary.usedPercent, - plan: card.planName, - windowMinutes: card.primary.windowMinutes, - resetsAt: card.primary.resetsAt, - }; -} - -type GrokAccountsCardSnapshot = Pick; - -function GrokAccountRow({ - account, - snapshot, - status, - hideEmail, - resetTimeRelative, - busy, - onSwitch, - onSignInAgain, -}: { - account: GrokAccount; - snapshot: GrokAccountUsage | undefined; - status: GrokAccountUsage["status"]; - hideEmail: boolean; - resetTimeRelative: boolean; - busy: boolean; - onSwitch: (id: string) => Promise; - onSignInAgain: (id: string) => Promise; -}) { - const { t } = useLocale(); - const email = hideEmail ? maskEmail(account.email) : account.email; - - return ( -
  • -
    -
    - - {email} - {account.isActive && ( - - {t("TokenAccountActive")} - - )} - - -
    - {status !== "signInRequired" && } - {status === "signInRequired" && } -
    -
  • - ); -} diff --git a/apps/desktop-tauri/src/components/InventoryRows.tsx b/apps/desktop-tauri/src/components/InventoryRows.tsx index c95e4f13f8..4a26b8545b 100644 --- a/apps/desktop-tauri/src/components/InventoryRows.tsx +++ b/apps/desktop-tauri/src/components/InventoryRows.tsx @@ -6,8 +6,8 @@ import { localizeProviderLabel } from "../lib/windowLabels"; /** * One discrete-inventory line: "{title}: {count} available [expiry]". * - * Shared by the tray menu card (`MenuCardDetails`) and the settings provider - * detail (`UsageSection`); each surface passes its own layout classes. + * Used by the settings provider detail (`UsageSection`); the tray card renders + * the same data as key-value rows in `card/DetailSections`. */ export function InventoryItemRow({ item, diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 2ab6b9edb5..6e85520ef3 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -83,6 +83,16 @@ function provider( }; } +/** Waits for a metric row whose title starts with `label` (the percent is an inline span). */ +function metricTitle(label: string) { + return waitFor(() => { + const titles = [...document.querySelectorAll(".menu-metric__title")]; + const match = titles.find((el) => el.firstChild?.textContent === label); + if (!match) throw new Error(`metric "${label}" not rendered yet`); + return match; + }); +} + function renderCard( snapshot: ProviderUsageSnapshot, opts: { @@ -210,13 +220,14 @@ describe("MenuCard", () => { eventMocks.listen.mockResolvedValue(() => {}); }); - it("keeps Codex sign-in discoverable beside an authentication error with no accounts", async () => { - tauriMocks.getCodexAccountsState.mockResolvedValueOnce({ accounts: [], accountOrdinals: {}, snapshots: {} }); + it("shows a Codex authentication error without embedding the account menu", async () => { const codex = { ...provider("Authentication required"), providerId: "codex", displayName: "Codex", errorState: "needsAuthentication" as const }; - render(); + const { container } = render(); expect(await screen.findByText("Authentication required")).toBeDefined(); - const add = await screen.findByRole("button", { name: "CodexAccountsSignInButton" }); - await waitFor(() => expect(add).toBeEnabled()); + // Account switching moved to Settings > Providers (the "Switch Account..." row). + expect(screen.queryByRole("button", { name: "CodexAccountsSignInButton" })).not.toBeInTheDocument(); + expect(container.querySelector(".codex-menu-accounts")).not.toBeInTheDocument(); + expect(tauriMocks.getCodexAccountsState).not.toHaveBeenCalled(); }); it("keeps Fireworks vendor API spend visible when local cost summaries are hidden", async () => { @@ -241,8 +252,8 @@ describe("MenuCard", () => { renderCard(snapshot, { costSummaryDisplayStyle: "hidden" }); - expect(await screen.findByText("API spend")).toBeInTheDocument(); - expect(document.querySelector(".menu-card__cost-line")).toHaveTextContent("$12.34"); + expect(await screen.findByText("API spend")).toHaveClass("card-extra__title"); + expect(document.querySelector(".card-extra__spend")).toHaveTextContent("30 days: $12.34"); }); it("filters hidden metric and extra rows without changing the snapshot payload", async () => { @@ -280,7 +291,7 @@ describe("MenuCard", () => { expect(screen.queryByText("Estimated from local logs")).not.toBeInTheDocument(); }); - it("shows DeepSeek peak/off-peak pricing status", async () => { + it("leaves DeepSeek pricing to Settings > Providers", async () => { tauriMocks.getDeepSeekPricingStatus.mockResolvedValue({ period: "offPeak", currentLocalTime: "2026-08-17 05:00:00 UTC", @@ -293,9 +304,9 @@ describe("MenuCard", () => { renderCard(snapshot); - expect(await screen.findByText("DeepSeek pricing: Off-peak hours")).toBeInTheDocument(); - expect(screen.getByText("Current local time: 2026-08-17 05:00:00 UTC")).toBeInTheDocument(); - expect(screen.getByText("Next transition: 2026-08-17 06:00:00 UTC")).toBeInTheDocument(); + await metricTitle("ProviderSessionLabel"); + expect(screen.queryByText("DeepSeek pricing: Off-peak hours")).not.toBeInTheDocument(); + expect(tauriMocks.getDeepSeekPricingStatus).not.toHaveBeenCalled(); }); it("can render metric bars as used instead of remaining", async () => { @@ -696,7 +707,7 @@ describe("MenuCard", () => { }); }); - it("shows the formatted predicted exhaustion time", async () => { + it("leaves the provider-level exhaustion ETA to Settings > Providers", async () => { const snapshot = provider(null, 40); snapshot.pace = { stage: "far_ahead", @@ -709,11 +720,9 @@ describe("MenuCard", () => { const { container } = renderCard(snapshot); - await waitFor(() => { - expect(container.querySelector(".menu-card__pace-eta")).toHaveTextContent( - "⚠ Runs out in 2h", - ); - }); + await metricTitle("ProviderSessionLabel"); + expect(container.querySelector(".menu-card__pace")).toBeNull(); + expect(screen.queryByText(/Runs out in 2h/)).not.toBeInTheDocument(); }); it("hides pace, budgets, and forecast text when Show pace is off", async () => { @@ -908,7 +917,6 @@ describe("MenuCard", () => { expect(await screen.findAllByText("0% used")).toHaveLength(2); expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); expect(container.querySelector(".menu-metric--blocked")).toBeNull(); - expect(container.querySelector(".menu-card__pace")).toBeInTheDocument(); }); it("lifts the block when the monthly pool resets while the card stays open", async () => { @@ -956,11 +964,9 @@ describe("MenuCard", () => { expect(screen.getAllByText("$1.23").length).toBeGreaterThan(0); expect(screen.getByText("30d tokens")).toBeInTheDocument(); expect(screen.getByText("584K")).toBeInTheDocument(); - expect(screen.getByText("Estimated from local logs")).toBeInTheDocument(); - const details = container.querySelector(".menu-card__more")!; - expect(details.open).toBe(false); - fireEvent.click(details.querySelector("summary")!); - expect(details.open).toBe(true); + expect(container.querySelector(".card-dash")).toBeInTheDocument(); + // No collapsed "usage details" disclosure: the dashboard is inline. + expect(container.querySelector("details")).toBeNull(); }); it("includes the matching daily token count in the local cost tooltip", async () => { @@ -987,7 +993,7 @@ describe("MenuCard", () => { const { container } = renderCard(snapshot); const bar = await waitFor(() => { - const element = container.querySelector(".menu-card__local-chart span"); + const element = container.querySelector(".card-dash__bar"); if (!element) throw new Error("local chart bar not rendered yet"); return element; }); @@ -1040,38 +1046,33 @@ describe("MenuCard", () => { ]; const detailed = renderCard(snapshot); - expect(await screen.findByText("Available balance: $95.50")).toBeInTheDocument(); - expect(screen.getAllByText("Available balance: $95.50")).toHaveLength(1); + const label = await screen.findByText("Available balance"); + expect(label).toHaveClass("card-kv__label"); + expect(screen.getAllByText("Available balance")).toHaveLength(1); + expect(label.closest(".card-kv__row")?.querySelector(".card-kv__value")).toHaveTextContent( + "$95.50", + ); detailed.unmount(); renderCard(snapshot, { compactOverview: true }); - expect(screen.queryByText("Available balance: $95.50")).not.toBeInTheDocument(); + await metricTitle("ProviderSessionLabel"); + expect(screen.queryByText("Available balance")).not.toBeInTheDocument(); }); - it("places Claude accounts above metrics and the collapsed usage details", async () => { + it("keeps Claude account switching out of the card", async () => { tauriMocks.claudeAccountsList.mockResolvedValue([ { id: "a", email: "a@example.com", organization: "Personal", isActive: true, isSaved: true }, { id: "b", email: "b@example.com", organization: "Work", isActive: false, isSaved: true }, ]); const { container } = renderCard(provider(null)); - await screen.findByText("ClaudeAccountsTitle"); - const accounts = container.querySelector(".codex-menu-accounts")!; - const metrics = container.querySelector(".menu-card__metrics")!; - expect(accounts.compareDocumentPosition(metrics) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - // The usage details block renders once its async data settles. - const more = await waitFor(() => { - const el = container.querySelector(".menu-card__more"); - expect(el).not.toBeNull(); - return el!; - }); - expect(more.open).toBe(false); + await metricTitle("ProviderSessionLabel"); + expect(screen.queryByText("ClaudeAccountsTitle")).not.toBeInTheDocument(); + expect(container.querySelector(".codex-menu-accounts")).toBeNull(); + expect(tauriMocks.claudeAccountsList).not.toHaveBeenCalled(); }); - it("shows on-pace budgets and expands projection details", async () => { - const onLayoutChange = vi.fn(); - const resetAt = new Date( - Date.now() + 0.6 * 7 * 24 * 60 * 60 * 1000, - ); + it("leaves on-pace budgets and the projection chart to Settings > Providers", async () => { + const resetAt = new Date(Date.now() + 0.6 * 7 * 24 * 60 * 60 * 1000); const snapshot = provider(null, 20); snapshot.primary = rateWindow(20, { reservePercent: 20, @@ -1080,39 +1081,12 @@ describe("MenuCard", () => { resetsAt: resetAt.toISOString(), }); - renderCard(snapshot, { onLayoutChange }); + renderCard(snapshot); - const toggle = await screen.findByRole("button", { name: /On-pace budget/ }); + await metricTitle("ProviderSessionLabel"); + expect(screen.queryByText(/On-pace budget/)).not.toBeInTheDocument(); expect(screen.queryByText("now 20%")).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: /PaceChartAriaLabel/i })).not.toBeInTheDocument(); - - fireEvent.click(toggle); - - expect(screen.getByText("now 20%")).toBeInTheDocument(); - expect(screen.getByText("1h 21%")).toBeInTheDocument(); - expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("img", { name: /PaceChartAriaLabel/i })).toBeInTheDocument(); - await waitFor(() => { - expect(onLayoutChange).toHaveBeenCalled(); - }); - }); - - it("shows on-pace budgets when timing exists without reserve metadata", async () => { - const resetAt = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000); - const snapshot = provider(null, 31); - snapshot.primary = rateWindow(31, { - windowMinutes: 7 * 24 * 60, - resetsAt: resetAt.toISOString(), - }); - - renderCard(snapshot); - - const toggle = await screen.findByRole("button", { name: /On-pace budget/ }); - expect(screen.queryByText("now 0%")).not.toBeInTheDocument(); - fireEvent.click(toggle); - expect(screen.getByText("now 0%")).toBeInTheDocument(); - expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); - expect(screen.queryByText("Lasts until reset")).not.toBeInTheDocument(); }); it("does not show pace budgets for a five-hour session window", async () => { @@ -1248,13 +1222,13 @@ describe("MenuCard", () => { }); - it("shows spend used/limit plus balance secondary line", async () => { + it("shows capped spend as an extra usage bar with percent and balance", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle({ - DetailCostTitle: "Cost", - DetailCostUsed: "Used", + ExtraUsage: "Extra usage", + UsedPercent: "{}% used", DetailCostBalance: "Balance", - DetailCostRemaining: "Remaining", + CostPeriodThisMonth: "This month", }), ); const snapshot = provider(null, 20); @@ -1263,7 +1237,7 @@ describe("MenuCard", () => { limit: 100, remaining: 87.5, currencyCode: "USD", - period: "Extra usage", + period: "This month", resetsAt: null, formattedUsed: "$12.50", formattedLimit: "$100.00", @@ -1271,20 +1245,23 @@ describe("MenuCard", () => { formattedBalance: "$25.50", }; - renderCard(snapshot); + const { container } = renderCard(snapshot); - expect(await screen.findByText(/Cost — Extra usage/)).toBeInTheDocument(); - expect(screen.getByText(/Used:\s*\$12\.50\s*\/\s*\$100\.00/)).toBeInTheDocument(); - expect(screen.getByText(/Balance:\s*\$25\.50/)).toBeInTheDocument(); + expect(await screen.findByText("Extra usage")).toHaveClass("card-extra__title"); + expect(container.querySelector(".card-extra__spend")).toHaveTextContent( + "This month: $12.50 / $100.00", + ); + expect(screen.getByText("13% used")).toBeInTheDocument(); + expect(screen.getByRole("progressbar", { name: "Extra usage" })).toHaveAttribute( + "aria-valuenow", + "12.5", + ); + expect(screen.getByText("Balance: $25.50")).toBeInTheDocument(); }); - it("renders balance-only cost as credits-style value", async () => { + it("renders balance-only cost as one inline extra usage row", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( - buildBundle({ - CreditsLabel: "Credits", - DetailCostTitle: "Cost", - DetailCostUsed: "Used", - }), + buildBundle({ ExtraUsage: "Extra usage", DetailCostBalance: "Balance" }), ); const snapshot = provider(null, 20); snapshot.cost = { @@ -1300,11 +1277,13 @@ describe("MenuCard", () => { formattedBalance: "$25.50", }; - renderCard(snapshot); + const { container } = renderCard(snapshot); expect(await screen.findByText("Extra usage")).toBeInTheDocument(); - expect(screen.getByText("$25.50")).toBeInTheDocument(); - expect(screen.queryByText(/Used:/)).not.toBeInTheDocument(); + expect( + container.querySelector(".card-extra--inline .card-extra__inline-value"), + ).toHaveTextContent("Balance: $25.50"); + expect(container.querySelector(".card-extra__track")).toBeNull(); }); it("localizes the relative updated-at time in Japanese without duplicated prefix", async () => { @@ -1325,13 +1304,12 @@ describe("MenuCard", () => { }); }); -// The SwiftUI fix this regression came from protecting a cached native -// measurement. The Windows card has no cached measurement layer: its live -// forecast is a normal flex row whose width is recomputed by WebView2. +// Upstream keeps the card a plain column; the session forecast moved to +// Settings > Providers > Usage details. const stylesSource = loadStyles(); -describe("MenuCard live forecast layout", () => { - it("renders the changing forecast in the current full-width flex row", async () => { +describe("MenuCard column layout", () => { + it("keeps the session forecast out of the card and lays the content out as a column", async () => { const snapshot = provider(null, 20); snapshot.secondary = rateWindow(35, { windowMinutes: 7 * 24 * 60 }); snapshot.secondaryLabel = "Weekly"; @@ -1345,12 +1323,8 @@ describe("MenuCard live forecast layout", () => { }; const { container } = renderCard(snapshot); - const forecast = await screen.findByText("Estimated: 123 session quotas left"); - const row = forecast.closest(".menu-metric__forecast"); - - expect(row).toBeInTheDocument(); - expect(row).toHaveClass("menu-metric__row"); - expect(row?.parentElement).toHaveClass("menu-metric"); + expect(await screen.findByText("ProviderWeeklyLabel")).toBeInTheDocument(); + expect(screen.queryByText(/session quotas left/)).not.toBeInTheDocument(); expect(container.querySelector(".menu-card__content")).toBeInTheDocument(); const card = ruleBlock(stylesSource, ".menu-card"); @@ -1358,16 +1332,5 @@ describe("MenuCard live forecast layout", () => { const content = ruleBlock(stylesSource, ".menu-card__content"); expect(content).toContain("display: flex"); expect(content).toContain("flex-direction: column"); - const metricRow = ruleBlock(stylesSource, ".menu-metric__row"); - expect(metricRow).toContain("min-width: 0"); - const forecastLabel = ruleBlock( - stylesSource, - ".menu-metric__forecast .menu-metric__pct", - ); - expect(forecastLabel).toContain("flex: 1 1 auto"); - expect(forecastLabel).toContain("min-width: 0"); - expect(forecastLabel).toContain("overflow: hidden"); - expect(forecastLabel).toContain("text-overflow: ellipsis"); - expect(forecastLabel).toContain("white-space: nowrap"); }); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 8e02537ff8..2a76046f5a 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, type ReactNode, useCallback, useEffect, useState } from "react"; +import { type CSSProperties, useEffect, useState } from "react"; import type { CostSummaryDisplayStyle, ProviderChartData, @@ -6,48 +6,22 @@ import type { } from "../types/bridge"; import { getProviderChartData } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; -import { formatRelativeUpdated } from "../lib/relativeTime"; -import type { LocaleKey } from "../i18n/keys"; import { providerSupportsChartData } from "../lib/providerCharts"; +import { providerAllowsPace } from "../lib/providerPace"; import { hideOpenAiApiProjectId } from "../lib/openAiApiIdentity"; -import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails"; -import CodexAccountsMenu from "./CodexAccountsMenu"; -import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; -import GrokAccountsMenu from "./GrokAccountsMenu"; -import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus"; -import { getDeepSeekPricingStatus } from "../lib/tauri"; -import type { DeepSeekPricingStatus } from "../types/bridge"; import { isUsageItemVisible } from "../lib/usageItemVisibility"; import { localizeWindowLabel } from "../lib/windowLabels"; +import { inlineDashboardModel } from "../lib/inlineDashboard"; import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; import type { UsageThresholdSettings } from "../lib/metricRowModel"; - -/** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ -function CopyIconButton({ text }: { text: string }) { - const { t } = useLocale(); - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(text).catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 900); - }, [text]); - return ( - - ); -} +import { getProviderIcon } from "./providers/providerIcons"; +import CardHeader, { displayPlanName } from "./card/CardHeader"; +import CardMetricRow, { type MetricEntry } from "./card/CardMetricRow"; +import DetailSections from "./card/DetailSections"; +import InlineDashboard from "./card/InlineDashboard"; +import ExtraUsageBlock from "./card/ExtraUsageBlock"; +import WayfinderBlock from "./card/WayfinderBlock"; +import { describeCard } from "./card/describeCard"; export interface MenuCardDisplayOptions { hideEmail: boolean; @@ -56,8 +30,8 @@ export interface MenuCardDisplayOptions { showPace?: boolean; showAsUsed?: boolean; /** - * Compact Overview layout: slice to the first two quota rows and omit - * supplemental content (wayfinder, cost, charts, extra texts). + * Compact Overview layout: the header and the quota rows only (plus the + * Wayfinder telemetry, which is that provider's quota). */ compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; @@ -69,44 +43,77 @@ interface MenuCardProps { provider: ProviderUsageSnapshot; display: MenuCardDisplayOptions; isRefreshing?: boolean; - /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ + /** Per-provider accent color override (hex); defaults to the brand color. */ accentColor?: string; onLayoutChange?: () => void; } - export function maskEmail(email: string): string { const at = email.indexOf("@"); if (at <= 1) return "••••@••••"; return email[0] + "•".repeat(at - 1) + email.slice(at); } -function displayPlanName( - planName: string | null, - t: (key: LocaleKey) => string, -): string | null { - if (!planName) return null; - const normalized = planName.trim().toLowerCase(); - if (normalized === "default_claude_ai") return t("ProviderPlanClaudeAi"); - return planName; +function cardMetrics( + provider: ProviderUsageSnapshot, + t: ReturnType["t"], + language: ReturnType["language"], +): MetricEntry[] { + const metrics: MetricEntry[] = []; + if (provider.providerId !== "wayfinder") { + metrics.push({ + id: "primary", + label: + localizeWindowLabel(provider.primaryLabel, t, language, provider.primary.windowMinutes) || + t("DetailWindowPrimary"), + snap: provider.primary, + lane: "primary", + }); + } + if (provider.secondary) { + metrics.push({ + id: "secondary", + label: localizeWindowLabel(provider.secondaryLabel, t) || t("DetailWindowSecondary"), + snap: provider.secondary, + lane: "secondary", + }); + } + if (provider.modelSpecific) { + metrics.push({ + id: "model-specific", + label: t("DetailWindowModelSpecific"), + snap: provider.modelSpecific, + lane: "tertiary", + }); + } + if (provider.tertiary) { + metrics.push({ + id: "tertiary", + // Upstream 0.48.0: the cadence label (e.g. "Monthly") when the provider names one. + label: localizeWindowLabel(provider.tertiaryLabel, t) || t("DetailWindowTertiary"), + snap: provider.tertiary, + lane: "tertiary", + }); + } + for (const extra of provider.extraRateWindows ?? []) { + metrics.push({ + id: `extra-${extra.id}`, + label: + localizeWindowLabel(extra.title, t, language, extra.window.windowMinutes, extra.id) || + extra.title, + snap: extra.window, + lane: "extra", + resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", + }); + } + return metrics.filter((metric) => isUsageItemVisible(provider.hiddenUsageItemIds, metric.id)); } /** - * Provider card — direct mirror of SwiftUI `UsageMenuCardView`. - * - * Layout (top to bottom): - * 1. Header VStack(spacing: 3) - * – HStack: providerName (headline/semibold) ·· email (subheadline/secondary, right) - * – HStack: subtitle "source · updated" ·· plan (footnote/secondary, right) - * 2. Divider (1pt) - * 3. VStack(spacing: 12) - * – Metrics group VStack(spacing: 12) of MetricRow - * – (Divider) Cost group: title (body/medium) + session line + month line (footnote) - * – (Divider) Pace group (Tauri-only addition; placed last) - * – (Divider) Charts group (Tauri-only addition; placed last) - * - * Padding: upstream v0.32.2 uses wider horizontal card padding and slightly - * taller header/content vertical padding so account/plan rows can breathe. + * Provider card, following upstream `UsageMenuCardView` (0.70.0), top to bottom: + * header, divider, rate-window rows, key-value sections, the inline usage + * dashboard, then the extra usage block. Account switching, pace budgets, + * forecasts, burndown and pricing details live in Settings > Providers. */ export default function MenuCard({ provider, @@ -128,16 +135,6 @@ export default function MenuCard({ } = display; const { t, language } = useLocale(); const [chartData, setChartData] = useState(null); - const [pricingStatus, setPricingStatus] = useState(null); - - useEffect(() => { - if (provider.providerId !== "deepseek") return; - const onPricing = (event: Event) => - setPricingStatus((event as CustomEvent).detail); - window.addEventListener(DEEPSEEK_PRICING_EVENT, onPricing); - void getDeepSeekPricingStatus().then(setPricingStatus).catch(() => {}); - return () => window.removeEventListener(DEEPSEEK_PRICING_EVENT, onPricing); - }, [provider.providerId]); // Another provider or account must not show the previous card's chart. useEffect(() => { @@ -149,14 +146,9 @@ export default function MenuCard({ // first mount without a full transcript walk on every refresh. const localDay = new Date().toLocaleDateString("sv"); useEffect(() => { - if (!providerSupportsChartData(provider.providerId)) { - return; - } + if (!providerSupportsChartData(provider.providerId)) return; let cancelled = false; - getProviderChartData( - provider.providerId, - provider.accountEmail ?? undefined, - ) + getProviderChartData(provider.providerId, provider.accountEmail ?? undefined) .then((data) => { if (!cancelled) { setChartData(data); @@ -172,204 +164,125 @@ export default function MenuCard({ }, [provider.providerId, provider.accountEmail, localDay, onLayoutChange]); const isWayfinder = provider.providerId === "wayfinder"; - const email = !isWayfinder && provider.accountEmail - ? hideEmail - ? maskEmail(provider.accountEmail) - : provider.accountEmail - : null; - const planName = !isWayfinder - ? displayPlanName(provider.planName, t) - : null; - const displayedPlanName = hideOpenAiApiProjectId( - planName, - provider.providerId === "openaiapi" && hideEmail, - ); + const email = + !isWayfinder && provider.accountEmail + ? hideEmail + ? maskEmail(provider.accountEmail) + : provider.accountEmail + : null; + const plan = isWayfinder + ? null + : hideOpenAiApiProjectId( + displayPlanName(provider.planName, t), + provider.providerId === "openaiapi" && hideEmail, + ); - const metrics: MetricEntry[] = [ - ...(isWayfinder - ? [] - : [ - { - id: "primary", - label: localizeWindowLabel(provider.primaryLabel, t, language, provider.primary.windowMinutes) || t("DetailWindowPrimary"), - snap: provider.primary, - lane: "primary" as const, - }, - ]), - ]; - if (provider.secondary) - metrics.push({ - id: "secondary", - label: localizeWindowLabel(provider.secondaryLabel, t) || t("DetailWindowSecondary"), - snap: provider.secondary, - lane: "secondary", - sessionEquivalentForecast: provider.sessionEquivalentForecast, - }); - if (provider.modelSpecific) - metrics.push({ - id: "model-specific", - label: t("DetailWindowModelSpecific"), - snap: provider.modelSpecific, - lane: "tertiary", - }); - if (provider.tertiary) - metrics.push({ - id: "tertiary", - // F5 (upstream 0.48.0): use the cadence-based label (e.g. "Monthly") instead - // of the generic "DetailWindowTertiary" slot key when tertiaryLabel is set. - label: localizeWindowLabel(provider.tertiaryLabel, t) || t("DetailWindowTertiary"), - snap: provider.tertiary, - lane: "tertiary", - }); - for (const extra of provider.extraRateWindows ?? []) { - metrics.push({ - id: `extra-${extra.id}`, - label: - localizeWindowLabel(extra.title, t, language, extra.window.windowMinutes, extra.id) || - extra.title, - snap: extra.window, - lane: "extra", - resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", - }); - } - const visibleMetrics = metrics.filter((metric) => - isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), - ); + const metrics = cardMetrics(provider, t, language); // Snapshots are cached across failed refreshes, so monthly-limit blocks are // re-checked against the clock and lift when the pool's reset passes. - const monthlyLimitBlockNow = useMonthlyLimitBlockNow([ - ...visibleMetrics.map((metric) => metric.snap.monthlyLimitBlock), - provider.pace?.monthlyLimitBlock, - ]); - + const monthlyLimitBlockNow = useMonthlyLimitBlockNow( + metrics.map((metric) => metric.snap.monthlyLimitBlock), + ); const presence = describeCard( provider, chartData, - visibleMetrics, + metrics, costSummaryDisplayStyle, - showPace, compactOverview, - monthlyLimitBlockNow, ); - const { hasDetails } = presence; + const dashboard = + !compactOverview && presence.localUsage + ? inlineDashboardModel({ + providerId: provider.providerId, + summary: presence.localUsage, + costHistory: chartData?.costHistory ?? [], + tokensHistory: chartData?.tokensHistory ?? [], + quotaHistory: chartData?.quotaWindowHistory?.windows, + t, + }) + : null; + const showDetailSections = !compactOverview && presence.hasDetailSections; + const showCost = !compactOverview && presence.hasCost && !!provider.cost; + const metricDisplay = { + resetTimeRelative, + showResetWhenExhausted, + showPace: showPace && providerAllowsPace(provider.providerId, provider.sourceLabel), + showAsUsed, + compactOverview, + monthlyLimitBlockNow, + usageThresholds, + weeklyProgressWorkDays, + }; + const cardClassName = [ "menu-card", provider.error ? "menu-card--error" : null, isRefreshing ? "menu-card--refreshing" : null, - hasDetails ? "menu-card--with-details" : "menu-card--header-only", + presence.hasDetails ? "menu-card--with-details" : "menu-card--header-only", ] .filter(Boolean) .join(" "); - - const renderHeader = (refreshAction?: ReactNode) => ( -
    -
    -
    - {provider.displayName} - {!provider.error && email && {email}} -
    - {refreshAction} -
    - {provider.error ? ( -
    -
    {provider.error}
    - -
    - ) : ( -
    - - {Number.isNaN(Date.parse(provider.updatedAt)) - ? provider.updatedAt - : formatRelativeUpdated(Date.parse(provider.updatedAt), t)} - - {displayedPlanName && ( - {displayedPlanName} - )} -
    - )} -
    - ); + const accent = accentColor ?? getProviderIcon(provider.providerId).brandColor; return (
    - {provider.providerId !== "grok" && renderHeader()} - - {provider.providerId === "codex" && ( - - )} - {provider.providerId === "claude" && ( - - )} - {provider.providerId === "grok" && ( - - )} - - {hasDetails &&
    } + - {hasDetails && ( - - )} + {presence.hasDetails &&
    } - {provider.providerId === "deepseek" && pricingStatus && ( -
    - - {t("DeepSeekPricingTitle")}: {t( - pricingStatus.period === "peak" - ? "DeepSeekPricingPeak" - : pricingStatus.period === "offPeak" - ? "DeepSeekPricingOffPeak" - : "DeepSeekPricingStandard", - )} - - - {t("DeepSeekPricingCurrent")} {pricingStatus.currentLocalTime} - - - {t("DeepSeekPricingNext")} {pricingStatus.nextTransitionLocalTime ?? "—"} - - - {t("DeepSeekPricingEffective")} {pricingStatus.effectiveLocalTime} - - {t("DeepSeekPricingAdvice")} -
    + {presence.hasDetails && ( +
    + {presence.hasMetrics && ( +
    + {metrics.map((metric) => ( + + ))} +
    + )} + {showDetailSections && ( + + )} + {presence.wayfinderUsage && } + {dashboard && } + {showCost && provider.cost && ( + <> + {(presence.hasMetrics || showDetailSections || dashboard) && ( +
    + )} + + + )} +
    )} -
    ); } diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx deleted file mode 100644 index c643af05da..0000000000 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ /dev/null @@ -1,905 +0,0 @@ -import { useEffect, useState } from "react"; -import type { - CostSummaryDisplayStyle, - DailyCostPoint, - DailyTokenPoint, - PaceSnapshot, - ProviderInventoryItem, - OpenAiApiUsageSnapshot, - ProviderChartData, - ProviderLocalUsageSummary, - ProviderUsageSnapshot, - RateWindowSnapshot, - SessionEquivalentForecastSnapshot, -} from "../types/bridge"; -import { useLocale } from "../hooks/useLocale"; -import { providerAllowsPace } from "../lib/providerPace"; -import { providerShowsDailyApiUsage } from "../lib/providerCharts"; -import { - useFormattedResetTime, - type ResetTimeFormatMode, -} from "../hooks/useFormattedResetTime"; -import { formatEta } from "../lib/formatEta"; -import type { LocaleKey } from "../i18n/keys"; -import { paceCategory } from "../surfaces/tray/paceCategory"; -import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; -import { InventoryItemRow } from "./InventoryRows"; -import { - groupProviderDisplayDetails, - ProviderDisplayRow, -} from "./ProviderDisplayRow"; -import { OpenAIApiUsageChart } from "./OpenAIApiUsageChart"; -import { QuotaWindowHistory } from "./QuotaWindowHistory"; -import QuotaBurndownChart from "./QuotaBurndownChart"; -import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; -import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; -import { periodCostLabel, periodTokensLabel } from "../lib/costPeriod"; -import { providerCostPeriodTitle } from "../lib/providerLabels"; -import { windowDetailText } from "../lib/usageWindows"; -import { localizeProviderText } from "../lib/providerText"; -import { localizeProviderLabel } from "../lib/windowLabels"; -import { isDetailSectionVisible } from "../lib/usageItemVisibility"; -import PaceDetailsChart from "./PaceDetailsChart"; -import UsageProgressBar from "./UsageProgressBar"; -import { - metricResetText, - metricRowPresentation, - type MetricLane, - type UsageThresholdSettings, -} from "../lib/metricRowModel"; - -/** Upstream session-quota estimate: "Estimated: {n} session quota(s) left". */ -function formatSessionEquivalentEstimate( - forecast: SessionEquivalentForecastSnapshot | null | undefined, -): string | null { - if (!forecast) return null; - const raw = forecast.estimatedWindowsToExhaustWeekly; - if (!Number.isFinite(raw)) return null; - const rounded = Math.round(Math.min(Math.max(raw, 0), 1_000_000) * 10) / 10; - const display = - Number.isInteger(rounded) || Math.abs(rounded - Math.round(rounded)) < 1e-9 - ? String(Math.round(rounded)) - : rounded.toFixed(1); - const unit = - rounded > 0 && rounded <= 1 ? "session quota" : "session quotas"; - return `Estimated: ${display} ${unit} left`; -} - -const currencyFormatters = new Map(); -const compactCountFormat0 = new Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 0, -}); -const compactCountFormat1 = new Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, -}); - -function formatCurrency(amount: number, code: string): string { - try { - let formatter = currencyFormatters.get(code); - if (!formatter) { - formatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: code, - }); - currencyFormatters.set(code, formatter); - } - return formatter.format(amount); - } catch { - return `${code} ${amount.toFixed(2)}`; - } -} - -function formatCompactCount(value: number | null): string { - if (value == null || value <= 0) return "—"; - return (value >= 1_000_000 ? compactCountFormat1 : compactCountFormat0).format( - value, - ); -} - -function formatLocalUsagePointTitle( - point: DailyCostPoint, - tokenCount: number | undefined, - tokenLabel: string, -): string { - if (point.value == null && tokenCount == null) return point.date; - const cost = point.value == null ? "—" : formatCurrency(point.value, "USD"); - const tokens = tokenCount == null ? "—" : `${formatCompactCount(tokenCount)} ${tokenLabel}`; - return `${point.date}: ${cost} · ${tokens}`; -} - -function formatBudget(value: number): string { - return value < 10 - ? value.toFixed(1).replace(/\.0$/, "") - : Math.round(value).toString(); -} - -function LocalUsageBlock({ - providerId, - summary, - costHistory, - tokensHistory, -}: { - providerId: string; - summary: ProviderLocalUsageSummary; - costHistory: DailyCostPoint[]; - tokensHistory: DailyTokenPoint[]; -}) { - const { t } = useLocale(); - // The selected History window; the histogram below stays a fixed 30 days. - const { reportingPeriod, periodCost, periodTokens } = summary; - - const isCodex = providerId === "codex"; - const isMuse = providerId === "muse"; - const visibleHistory = costHistory.slice(-30); - const maxCost = Math.max( - ...visibleHistory.flatMap((point) => (point.value == null ? [] : [point.value])), - 0, - ); - const tokensByDate = new Map(tokensHistory.map((point) => [point.date, point.tokens])); - - return ( -
    -
    -
    - {t("PanelToday")} - - {isMuse - ? (summary.latestTokens != null - ? formatCompactCount(summary.latestTokens) - : "—") - : summary.todayCost != null - ? formatCurrency(summary.todayCost, "USD") - : "—"} - -
    - {!isMuse && ( -
    - - {periodCostLabel(reportingPeriod, t)} - - - {periodCost != null ? formatCurrency(periodCost, "USD") : "—"} - -
    - )} -
    - - {periodTokensLabel(reportingPeriod, t)} - - {formatCompactCount(periodTokens)} -
    - {!isMuse && ( -
    - {t("PanelLatestTokens")} - {formatCompactCount(summary.latestTokens)} -
    - )} -
    - - {isCodex && visibleHistory.length > 0 && ( -
    - {visibleHistory.map((point, index) => ( - - ))} -
    - )} - - {summary.incompleteRequestCount != null && summary.incompleteRequestCount > 0 && ( -
    - {t("IncompleteRequestsLabel")} - - {t("IncompleteRequestsDetail").replace("{}", String(summary.incompleteRequestCount))} - -
    - )} - -
    - {summary.topModel && {t("PanelTopModelPrefix")}: {summary.topModel}} - - {summary.estimateNote === "Estimated from local logs" - ? t("PanelEstimatedFromLocalLogs") - : summary.estimateNote} - -
    -
    - ); -} - -function WayfinderUsageBlock({ - usage, -}: { - usage: NonNullable; -}) { - const { t } = useLocale(); - const formatAmount = (value: number) => - usage.priced ? `${value.toFixed(4)} ${usage.unit.toUpperCase()}` : "—"; - - return ( -
    -
    -
    - {t("WayfinderGatewayStatus")} - {usage.gatewayStatus} -
    -
    - {t("WayfinderModels")} - {usage.modelCount} -
    -
    - {t("WayfinderRequests")} - {formatCompactCount(usage.requests)} -
    -
    - {t("WayfinderTokens")} - {formatCompactCount(usage.tokens)} -
    -
    -
    - {t("WayfinderSaved")}: {formatAmount(usage.saved)} ({usage.savedPercent.toFixed(1)}%) -
    - {(usage.offline || usage.dryRun || usage.missingKeys.length > 0) && ( -
    - {usage.offline && {t("WayfinderOffline")}} - {usage.dryRun && {t("WayfinderDryRun")}} - {usage.missingKeys.length > 0 && ( - {t("WayfinderMissingKeys")}: {usage.missingKeys.join(", ")} - )} -
    - )} -
    - ); -} - -function paceStageKey(stage: PaceSnapshot["stage"]): LocaleKey { - switch (stage) { - case "on_track": - return "DetailPaceOnTrack"; - case "slightly_ahead": - return "DetailPaceSlightlyAhead"; - case "ahead": - return "DetailPaceAhead"; - case "far_ahead": - return "DetailPaceFarAhead"; - case "slightly_behind": - return "DetailPaceSlightlyBehind"; - case "behind": - return "DetailPaceBehind"; - case "far_behind": - return "DetailPaceFarBehind"; - default: - return "DetailPaceOnTrack"; - } -} - -const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60; - -export interface MetricEntry { - id: string; - label: string; - snap: RateWindowSnapshot; - lane: MetricLane; - resetFormatMode?: ResetTimeFormatMode; - sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; -} - -function metricPaceBudget(snap: RateWindowSnapshot): PaceBudget | null { - if (snap.isExhausted) return null; - const isWeeklyWindow = - snap.windowMinutes != null && snap.windowMinutes >= WEEKLY_WINDOW_MINUTES; - return isWeeklyWindow ? getPaceBudget(snap) : null; -} - -type MetricRowDisplay = { - resetTimeRelative: boolean; - showResetWhenExhausted?: boolean; - showPace?: boolean; - showAsUsed?: boolean; - compactOverview?: boolean; - costSummaryDisplayStyle?: CostSummaryDisplayStyle; - monthlyLimitBlockNow?: number; - usageThresholds?: UsageThresholdSettings | null; - weeklyProgressWorkDays?: number | null; -}; - -function useCountdownNow(ticking: boolean): number { - const [, setTick] = useState(0); - useEffect(() => { - if (!ticking) return; - const id = window.setInterval(() => setTick((tick) => tick + 1), 30_000); - return () => window.clearInterval(id); - }, [ticking]); - return Date.now(); -} - -function MetricRow({ - title, - snap, - lane, - providerId, - exhaustedLabel, - display, - expanded, - onToggleExpanded, - resetFormatMode, - sessionEquivalentForecast, -}: { - title: string; - snap: RateWindowSnapshot; - lane: MetricLane; - providerId: string; - exhaustedLabel: string; - display: MetricRowDisplay; - expanded: boolean; - onToggleExpanded: () => void; - resetFormatMode?: ResetTimeFormatMode; - sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; -}) { - const { t } = useLocale(); - const { - resetTimeRelative, - showResetWhenExhausted = false, - showPace = true, - showAsUsed = false, - compactOverview = false, - monthlyLimitBlockNow, - usageThresholds = null, - weeklyProgressWorkDays = null, - } = display; - // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) - // blocks this window until the pool resets. Raw percentages stay untouched. - const blocked = isMonthlyLimitBlockActive( - snap.monthlyLimitBlock, - monthlyLimitBlockNow ?? Date.now(), - ); - const isInformational = snap.isInformational === true; - const informationalReset = useFormattedResetTime( - isInformational && !blocked ? snap.resetsAt : null, - null, - resetTimeRelative, - resetFormatMode ?? "reset", - ); - const now = useCountdownNow(!isInformational && !blocked && snap.resetsAt != null); - const detailText = localizeProviderText(windowDetailText(snap), t) || null; - if (blocked) { - // Upstream `MetricRow` status layout: title plus one secondary status line, - // no bar, percent, reset, pace, or forecast. The pool's own row keeps its - // reset; the shorter resets cannot restore access. - return ( -
    - {title} - {t("PanelBlockedByMonthlyLimit")} -
    - ); - } - if (isInformational) { - const infoPrimary = - localizeProviderText(snap.resetDescription?.trim(), t) || informationalReset || "—"; - return ( -
    - {title} -
    - {infoPrimary} - {!compactOverview && - snap.resetDescription?.trim() && - informationalReset && - informationalReset !== infoPrimary && ( - {informationalReset} - )} -
    - {!compactOverview && detailText && ( -
    {detailText}
    - )} -
    - ); - } - const row = metricRowPresentation( - { - snap, - lane, - providerId, - resetText: metricResetText(snap, resetTimeRelative, now, t), - compact: compactOverview, - showAsUsed, - showResetWhenExhausted, - paceEnabled: showPace, - usageThresholds, - weeklyProgressWorkDays, - now, - }, - t, - ); - const budget = showPace && !compactOverview ? metricPaceBudget(snap) : null; - const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); - return ( -
    -
    - - {title} - {row.percentText && ( - <> - {" "} - {row.percentText} - - )} - - {row.resetText && {row.resetText}} -
    - - {row.metaText &&
    {row.metaText}
    } - {!compactOverview && detailText && ( -
    {detailText}
    - )} - {!compactOverview && snap.isExhausted && ( -
    {exhaustedLabel}
    - )} - {budget && ( -
    - - {expanded &&
    - {[ - [t("PanelNow"), budget.now], - [t("PanelOneHour"), budget.nextHour], - [t("PanelFiveHours"), budget.nextFiveHours], - [t("PanelTodayBudget"), budget.today], - ].map(([label, value]) => ( - - {label} {formatBudget(Number(value))}% - - ))} -
    } - {expanded && } -
    - )} - {!compactOverview && showPace && forecastText && ( -
    - {forecastText} -
    - )} -
    - ); -} - -export interface MenuCardPresence { - hasMetrics: boolean; - hasInventory: boolean; - hasDisplayDetails: boolean; - hasCost: boolean; - hasPace: boolean; - hasCharts: boolean; - hasCostHistory: boolean; - hasCreditsHistory: boolean; - hasUsageBreakdown: boolean; - hasQuotaWindowHistory: boolean; - hasBurndown: boolean; - localUsage: ProviderChartData["localUsage"] | null; - wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; - /** Per-day API usage history (OpenAI Admin API, Groq console); only with data. */ - openAiApiUsage: OpenAiApiUsageSnapshot | null; - hasDetails: boolean; -} - -export interface MenuCardDetailsProps { - provider: ProviderUsageSnapshot; - display: MetricRowDisplay; - metrics: MetricEntry[]; - chartData: ProviderChartData | null; - presence: MenuCardPresence; - onLayoutChange?: () => void; -} - -/** - * Single source of truth for "does this card have a body" and which sections - * are present. Pure; computed once in `MenuCard` and threaded into - * `MenuCardDetails` so the two files never diverge on the predicate suite. - */ -export function describeCard( - provider: ProviderUsageSnapshot, - chartData: ProviderChartData | null, - visibleMetrics: MetricEntry[], - costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", - showPace = true, - compactOverview = false, - monthlyLimitBlockNow: number = Date.now(), -): MenuCardPresence { - const hasCostHistory = - chartData !== null && chartData.costHistory.some((point) => point.value != null); - const hasCreditsHistory = - chartData !== null && chartData.creditsHistory.length > 0; - const hasUsageBreakdown = - chartData !== null && chartData.usageBreakdown.length > 0; - const hasQuotaWindowHistory = - chartData !== null && (chartData.quotaWindowHistory?.windows.length ?? 0) > 0; - const hasBurndown = provider.quotaBurndown != null; - const hasCharts = - hasCostHistory || hasCreditsHistory || hasUsageBreakdown || hasQuotaWindowHistory; - const isWayfinder = provider.providerId === "wayfinder"; - const localUsage = provider.error ? null : chartData?.localUsage ?? null; - const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; - const openAiApiUsage = - providerShowsDailyApiUsage(provider.providerId) && - !provider.error && - (provider.openAiApiUsage?.daily.length ?? 0) > 0 - ? (provider.openAiApiUsage ?? null) - : null; - const hasMetrics = visibleMetrics.length > 0; - const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; - const hasDisplayDetails = !provider.error && (provider.displayDetails?.length ?? 0) > 0; - const hasCost = - !!provider.cost && - (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); - const hasPace = - showPace && - providerAllowsPace(provider.providerId, provider.sourceLabel) && - !!provider.pace && - !isMonthlyLimitBlockActive(provider.pace.monthlyLimitBlock, monthlyLimitBlockNow); - const hasDetails = - !provider.error && - (hasMetrics || - hasInventory || - hasDisplayDetails || - hasCost || - hasPace || - hasCharts || - !!localUsage || - !!wayfinderUsage || - !!openAiApiUsage) && - // Compact Overview suppresses supplemental sections entirely; a card - // whose only content would be suppressed renders header-only so no empty - // divider or details container appears. - (!compactOverview || hasMetrics || !!wayfinderUsage || hasPace); - return { - hasMetrics, - hasInventory, - hasDisplayDetails, - hasCost, - hasPace, - hasCharts, - hasCostHistory, - hasCreditsHistory, - hasUsageBreakdown, - hasQuotaWindowHistory, - hasBurndown, - localUsage, - wayfinderUsage, - openAiApiUsage, - hasDetails, - }; -} - -/** Metrics / cost / pace / charts body of a provider MenuCard. */ -export default function MenuCardDetails({ - provider, - display, - metrics, - chartData, - presence, - onLayoutChange, -}: MenuCardDetailsProps) { - const { t } = useLocale(); - const paceEnabled = - display.showPace !== false && - providerAllowsPace(provider.providerId, provider.sourceLabel); - const metricDisplay = paceEnabled ? display : { ...display, showPace: false }; - const compactOverview = display.compactOverview === true; - const [expandedPaceWindow, setExpandedPaceWindow] = useState(null); - const formattedCostReset = useFormattedResetTime( - provider.cost?.resetsAt ?? null, - null, - display.resetTimeRelative, - ); - const localCostHistory = chartData?.costHistory ?? []; - const localTokensHistory = chartData?.tokensHistory ?? []; - const costStyle = display.costSummaryDisplayStyle ?? "detailed"; - const displayDetailGroups = groupProviderDisplayDetails( - provider.displayDetails ?? [], - ); - const costPeriod = providerCostPeriodTitle( - provider.providerId, - provider.cost?.period ?? "", - t, - ); - - const { - hasMetrics, - hasInventory, - hasDisplayDetails, - hasCost, - hasPace, - hasCharts, - hasCostHistory, - hasCreditsHistory, - hasUsageBreakdown, - hasQuotaWindowHistory, - hasBurndown, - localUsage, - wayfinderUsage, - openAiApiUsage, - } = presence; - - return ( -
    - {!provider.error && hasMetrics && ( -
    - {metrics.map((m) => ( - { - setExpandedPaceWindow((current) => - current === m.id ? null : m.id, - ); - requestAnimationFrame(() => onLayoutChange?.()); - }} - /> - ))} -
    - )} - - {!provider.error && hasInventory && ( -
    - {provider.inventory?.map((item) => ( - - ))} -
    - )} - {!provider.error && hasDisplayDetails && ( - displayDetailGroups - .map((group) => ({ - ...group, - rows: group.rows.filter((detail) => - isDetailSectionVisible(provider.hiddenUsageItemIds, detail.title), - ), - })) - .filter((group) => group.rows.length > 0) - .map((group) => ( -
    - {group.title && ( -
    - {localizeProviderLabel(group.title, t)} -
    - )} - {group.rows.map((detail, index) => ( - - ))} -
    - )) - )} - - {wayfinderUsage && !compactOverview && } - - {!compactOverview && hasMetrics && hasCost &&
    } - - {!compactOverview && hasCost && provider.cost && ( -
    -
    - {provider.cost.alwaysVisible === true && (provider.cost.limit ?? 0) <= 0 - ? t("ApiSpendTitle") - : provider.cost.balance != null && provider.cost.limit == null - ? provider.cost.period || t("CreditsLabel") - : `${t("DetailCostTitle")} — ${provider.cost.period}`} -
    - {provider.cost.balance != null && provider.cost.limit == null ? ( -
    - {provider.cost.formattedBalance || - formatCurrency( - provider.cost.balance, - provider.cost.currencyCode, - )} -
    - ) : ( - <> -
    - {t("DetailCostUsed")}:{" "} - {provider.cost.formattedUsed || - formatCurrency( - provider.cost.used, - provider.cost.currencyCode, - )} - {provider.cost.limit != null && ( - <> - {" / "} - {provider.cost.formattedLimit || - formatCurrency( - provider.cost.limit, - provider.cost.currencyCode, - )} - - )} -
    - {costStyle === "detailed" && provider.cost.balance != null && ( -
    - {t("DetailCostBalance")}:{" "} - {provider.cost.formattedBalance || - formatCurrency( - provider.cost.balance, - provider.cost.currencyCode, - )} -
    - )} - {costStyle === "detailed" && provider.cost.remaining != null && ( -
    - {t("DetailCostRemaining")}:{" "} - {formatCurrency( - provider.cost.remaining, - provider.cost.currencyCode, - )} -
    - )} - {costStyle === "detailed" && formattedCostReset && ( -
    - {t("DetailCostResets")}: {formattedCostReset} -
    - )} - - )} - {provider.providerId === "mistral" && provider.cost && ( -
    - {t("MistralMonthlySpend")}:{" "} - {provider.cost.currencySymbol - ? `${provider.cost.currencySymbol}${provider.cost.used.toFixed(2)}` - : provider.cost.formattedUsed} -
    - )} -
    - )} - - {!compactOverview && openAiApiUsage && ( -
    - {t("OpenAIChartTitle")} -
    - {/* Tray cards open often; skip the bar entrance animation there. */} - -
    -
    - )} - - {!compactOverview && (localUsage || hasPace || hasCharts) && ( -
    - {t("PanelUsageDetails")} -
    - {localUsage && ( - - )} - - {paceEnabled && hasPace && provider.pace && ( -
    -
    - {t("DetailPaceTitle")} - - {t(paceStageKey(provider.pace.stage))} ( - {provider.pace.deltaPercent >= 0 ? "+" : ""} - {provider.pace.deltaPercent.toFixed(1)}%) - -
    -
    -
    -
    -
    -
    -
    -
    -
    - {provider.pace.etaSeconds != null && !provider.pace.willLastToReset && ( -
    - ⚠{" "} - {t("DetailPaceRunsOutIn")} {formatEta(provider.pace.etaSeconds)} -
    - )} - {provider.pace.willLastToReset && ( -
    - ✓ {t("DetailPaceWillLastToReset")} -
    - )} -
    - )} - - {(hasMetrics || hasCost || hasPace) && hasCharts && ( -
    - )} - - {hasCharts && ( -
    - {hasCostHistory && ( - `$${v.toFixed(2)}`} - t={t} - /> - )} - {hasCreditsHistory && ( - v.toFixed(1)} - t={t} - /> - )} - {hasUsageBreakdown && ( - - )} - {hasQuotaWindowHistory && ( - - )} - {hasBurndown && provider.quotaBurndown && ( - - )} -
    - )} -
    -
    - )} -
    - ); -} diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx index 5c709b04e0..d5d9c19807 100644 --- a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -23,7 +23,7 @@ import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; import type { OpenAiApiUsageSnapshot, ProviderUsageSnapshot } from "../types/bridge"; import MenuCard from "./MenuCard"; -import { describeCard } from "./MenuCardDetails"; +import { describeCard } from "./card/describeCard"; const START = 1_788_220_800; @@ -106,7 +106,7 @@ function renderCard( ); } -describe("MenuCard OpenAI daily usage section", () => { +describe("MenuCard OpenAI daily usage", () => { beforeEach(() => { vi.clearAllMocks(); tauriMocks.claudeAccountsList.mockResolvedValue([]); @@ -122,80 +122,68 @@ describe("MenuCard OpenAI daily usage section", () => { eventMocks.listen.mockResolvedValue(() => {}); }); - it("renders a collapsed Daily usage section for openaiapi", async () => { + // The per-day API chart lives in Settings > Providers (ChartsSection); the + // card keeps the upstream anatomy only. + it("keeps the per-day API chart out of the card", async () => { const { container } = renderCard(snapshot("openaiapi", usage())); - await waitFor(() => { - expect(container.querySelector(".menu-card__daily-usage")).not.toBeNull(); - }); - const section = container.querySelector(".menu-card__daily-usage") as HTMLDetailsElement; - expect(section.tagName).toBe("DETAILS"); - expect(section.open).toBe(false); - expect(section.querySelector("summary")).toHaveTextContent("OpenAIChartTitle"); - expect(section.querySelectorAll('[role="option"]')).toHaveLength(2); - }); - - it("renders the Daily usage section for the Groq console history", async () => { - const { container } = renderCard(snapshot("groq", usage(3))); - await waitFor(() => { - expect(container.querySelector(".menu-card__daily-usage")).not.toBeNull(); - }); - const section = container.querySelector(".menu-card__daily-usage") as HTMLDetailsElement; - expect(section.querySelectorAll('[role="option"]')).toHaveLength(3); - }); - - it("does not show another provider's OpenAI payload (provider siloing)", async () => { - const { container } = renderCard(snapshot("claude", usage())); - await waitFor(() => { - expect(tauriMocks.getProviderChartData).toHaveBeenCalled(); - }); + await waitFor(() => expect(container.querySelector(".card-header__name")).not.toBeNull()); expect(container.querySelector(".menu-card__daily-usage")).toBeNull(); expect(container.querySelector('[role="option"]')).toBeNull(); + expect(container).not.toHaveTextContent("OpenAIChartTitle"); }); it("masks the Admin project id in the card identity when privacy is enabled", async () => { const provider = snapshot("openaiapi", usage()); provider.planName = "Admin API: proj-private"; const { container } = renderCard(provider, false, true); - await waitFor(() => expect(container.querySelector(".menu-card__plan-badge")).not.toBeNull()); - expect(container.querySelector(".menu-card__plan-badge")).toHaveTextContent("Admin API: ••••"); + await waitFor(() => expect(container.querySelector(".card-header__plan")).not.toBeNull()); + expect(container.querySelector(".card-header__plan")).toHaveTextContent("Admin API: ••••"); expect(container).not.toHaveTextContent("proj-private"); }); - - it("hides the section on error, in compact overview and with an empty window", async () => { - const errored = renderCard(snapshot("openaiapi", usage(), "boom")); - await waitFor(() => expect(tauriMocks.getLocaleStrings).toHaveBeenCalled()); - expect(errored.container.querySelector(".menu-card__daily-usage")).toBeNull(); - errored.unmount(); - - const compact = renderCard(snapshot("openaiapi", usage()), true); - expect(compact.container.querySelector(".menu-card__daily-usage")).toBeNull(); - compact.unmount(); - - const empty = renderCard(snapshot("openaiapi", usage(0))); - expect(empty.container.querySelector(".menu-card__daily-usage")).toBeNull(); - }); }); -describe("describeCard OpenAI usage presence", () => { - it("is present only for openaiapi with days and no error", () => { - expect(describeCard(snapshot("openaiapi", usage()), null, []).openAiApiUsage).not.toBeNull(); - expect(describeCard(snapshot("claude", usage()), null, []).openAiApiUsage).toBeNull(); - expect(describeCard(snapshot("openaiapi", usage(0)), null, []).openAiApiUsage).toBeNull(); - expect(describeCard(snapshot("openaiapi", null), null, []).openAiApiUsage).toBeNull(); - expect(describeCard(snapshot("openaiapi", usage(), "boom"), null, []).openAiApiUsage).toBeNull(); +describe("describeCard", () => { + it("does not count the per-day API history as card content", () => { + const presence = describeCard(snapshot("openaiapi", usage()), null, []); + expect(presence.hasDetails).toBe(false); }); - it("is present for the Groq console daily history", () => { - expect(describeCard(snapshot("groq", usage()), null, []).openAiApiUsage?.daily).toHaveLength(2); - expect(describeCard(snapshot("groq", usage(), "boom"), null, []).openAiApiUsage).toBeNull(); - }); - - it("makes a card that only has daily usage count as having details", () => { - expect(describeCard(snapshot("openaiapi", usage()), null, []).hasDetails).toBe(true); - expect(describeCard(snapshot("openaiapi", usage(0)), null, []).hasDetails).toBe(false); + it("drops every section on error", () => { + const errored = snapshot("openaiapi", usage(), "boom"); + errored.cost = { + used: 1, + limit: null, + remaining: null, + currencyCode: "USD", + currencySymbol: "$", + period: "30 days", + resetsAt: null, + formattedUsed: "$1.00", + formattedLimit: null, + balance: null, + formattedBalance: null, + daily: [], + alwaysVisible: true, + }; + const presence = describeCard(errored, null, [ + { id: "primary", label: "Session", snap: errored.primary, lane: "primary" }, + ]); + expect(presence).toMatchObject({ + hasMetrics: false, + hasDetailSections: false, + hasCost: false, + localUsage: null, + wayfinderUsage: null, + hasDetails: false, + }); }); - it("keeps compact overview header-only when daily usage is the only content", () => { - expect(describeCard(snapshot("openaiapi", usage()), null, [], "detailed", true, true).hasDetails).toBe(false); + it("keeps compact Overview to quota rows", () => { + const snap = snapshot("openaiapi", usage()); + snap.displayDetails = [ + { id: "d", sectionTitle: null, title: "Plan", value: "Pro", secondaryValue: null, progress: null }, + ]; + expect(describeCard(snap, null, [], "detailed", false).hasDetails).toBe(true); + expect(describeCard(snap, null, [], "detailed", true).hasDetails).toBe(false); }); }); diff --git a/apps/desktop-tauri/src/components/ProviderAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ProviderAccountsMenu.test.tsx deleted file mode 100644 index 49f136f758..0000000000 --- a/apps/desktop-tauri/src/components/ProviderAccountsMenu.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import ProviderAccountsMenu from "./ProviderAccountsMenu"; - -describe("ProviderAccountsMenu", () => { - it("shows accounts and the add action by default, and preserves a collapse across updates", async () => { - const onLayoutChange = vi.fn(); - const content = (count: number) => Add account}> -
    • Saved account
    -
    ; - const { container, rerender } = render(content(1)); - const details = container.querySelector("details")!; - expect(details.open).toBe(true); - const add = screen.getByRole("button", { name: "Add account" }); - expect(screen.getByText("Saved account").compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - fireEvent.click(screen.getByText("Accounts")); - await waitFor(() => expect(details.open).toBe(false)); - await waitFor(() => expect(onLayoutChange).toHaveBeenCalled()); - rerender(content(2)); - expect(details.open).toBe(false); - fireEvent.click(screen.getByText("Accounts")); - await waitFor(() => expect(details.open).toBe(true)); - expect(screen.getByRole("button", { name: "Add account" })).toBeEnabled(); - }); -}); diff --git a/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx b/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx deleted file mode 100644 index 5b29367cdc..0000000000 --- a/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useState, type ComponentPropsWithoutRef, type ReactNode } from "react"; - -type Props = Omit, "children" | "onToggle" | "open" | "title"> & { - title: string; - count: number; - children: ReactNode; - actions: ReactNode; - onLayoutChange?: () => void; -}; - -/** Shared account list and footer for providers with managed logins. */ -export default function ProviderAccountsMenu({ title, count, children, actions, onLayoutChange, ...props }: Props) { - const [expanded, setExpanded] = useState(true); - return ( -
    { - setExpanded(event.currentTarget.open); - onLayoutChange?.(); - }} - > - - {title} - {count} - - {children} -
    {actions}
    -
    - ); -} diff --git a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx index 069d38de25..d8c89432bc 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -36,8 +36,8 @@ export function groupProviderDisplayDetails( * One transient provider detail line: "{title}: {value} [secondary]" * plus an optional clamped progress bar. * - * Shared by the tray menu card (`MenuCardDetails`) and the settings provider - * detail (`UsageSection`); each surface passes its own layout classes. + * Used by the settings provider detail (`UsageSection`); the tray card renders + * the same data as key-value rows in `card/DetailSections`. */ export function ProviderDisplayRow({ detail, diff --git a/apps/desktop-tauri/src/components/card/CardHeader.test.tsx b/apps/desktop-tauri/src/components/card/CardHeader.test.tsx new file mode 100644 index 0000000000..96739370a5 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/CardHeader.test.tsx @@ -0,0 +1,136 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const STRINGS: Record = { + ProviderPlanClaudeAi: "Claude AI", + UsageSpendRefreshing: "Refreshing…", + ProviderNotFetchedYetTitle: "Not fetched yet", + UpdatedJustNow: "Updated just now", + UpdatedMinutesAgo: "Updated {}m ago", + PanelCopied: "Copied", + ActionCopyError: "Copy error", +}; +vi.mock("../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => STRINGS[key] ?? key, language: "english" }), +})); + +import CardHeader, { + displayPlanName, + headerSubtitle, + splitForMiddleTruncation, +} from "./CardHeader"; + +const t = (key: string) => STRINGS[key] ?? key; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("displayPlanName", () => { + it("keeps a real plan name", () => { + expect(displayPlanName(" Pro 20x ", t)).toBe("Pro 20x"); + }); + + it("maps the raw Claude AI tier", () => { + expect(displayPlanName("default_claude_ai", t)).toBe("Claude AI"); + }); + + it("drops the source-only Claude login labels", () => { + expect(displayPlanName("Claude (OAuth)", t)).toBeNull(); + expect(displayPlanName("Claude (CLI)", t)).toBeNull(); + }); + + it("returns nothing for a blank plan", () => { + expect(displayPlanName(null, t)).toBeNull(); + expect(displayPlanName(" ", t)).toBeNull(); + }); +}); + +describe("headerSubtitle", () => { + const now = Date.parse("2026-10-11T12:00:00Z"); + + it("prefers Refreshing… while a refresh runs", () => { + expect(headerSubtitle("2026-10-11T11:00:00Z", true, t, now)).toBe("Refreshing…"); + }); + + it("formats a parsed time as a relative update", () => { + expect(headerSubtitle("2026-10-11T11:59:30Z", false, t, now)).toBe("Updated just now"); + expect(headerSubtitle("2026-10-11T11:55:00Z", false, t, now)).toBe("Updated 5m ago"); + }); + + it("says Not fetched yet without a time", () => { + expect(headerSubtitle("", false, t, now)).toBe("Not fetched yet"); + expect(headerSubtitle(null, false, t, now)).toBe("Not fetched yet"); + }); + + it("passes an unparseable label through", () => { + expect(headerSubtitle("cached", false, t, now)).toBe("cached"); + }); +}); + +describe("splitForMiddleTruncation", () => { + it("splits the text into a head and a tail", () => { + expect(splitForMiddleTruncation("parity.user@example.com")).toEqual([ + "parity.user@", + "example.com", + ]); + }); +}); + +describe("CardHeader", () => { + it("shows the name, the email, the subtitle and the plan", () => { + const { container } = render( + , + ); + expect(screen.getByText("Codex")).toHaveClass("card-header__name"); + expect(container.querySelector(".card-header__email")).toHaveTextContent( + "parity.user@example.com", + ); + expect(screen.getByText("Updated just now")).toHaveClass("card-header__subtitle"); + expect(screen.getByText("Pro 20x")).toHaveClass("card-header__plan"); + }); + + it("keeps the email and plan beside an error and copies the error text", async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + const { container } = render( + , + ); + expect(screen.getByText("Authentication required")).toHaveClass("card-header__error"); + expect(container.querySelector(".card-header__email")).toHaveTextContent("a@b.c"); + expect(screen.getByText("Max")).toBeInTheDocument(); + expect(container.querySelector(".card-header__subtitle")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Copy error" })); + expect(writeText).toHaveBeenCalledWith("Authentication required"); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(900); + }); + expect(screen.getByRole("button", { name: "Copy error" })).toBeInTheDocument(); + }); + + it("omits the email and plan when there are none", () => { + const { container } = render( + , + ); + expect(container.querySelector(".card-header__email")).toBeNull(); + expect(container.querySelector(".card-header__plan")).toBeNull(); + expect(screen.getByText("Refreshing…")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/components/card/CardHeader.tsx b/apps/desktop-tauri/src/components/card/CardHeader.tsx new file mode 100644 index 0000000000..0b1f208369 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/CardHeader.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { LocaleKey } from "../../i18n/keys"; +import { useLocale } from "../../hooks/useLocale"; +import { formatRelativeUpdated } from "../../lib/relativeTime"; + +type Translate = (key: LocaleKey) => string; + +/** Upstream `CopyIconButton`: doc.on.doc, then a checkmark for 0.9 s. */ +export function CopyIconButton({ text }: { text: string }) { + const { t } = useLocale(); + const [copied, setCopied] = useState(false); + const timer = useRef(null); + useEffect( + () => () => { + if (timer.current != null) window.clearTimeout(timer.current); + }, + [], + ); + const handleCopy = useCallback(() => { + navigator.clipboard?.writeText(text).catch(() => {}); + setCopied(true); + if (timer.current != null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 900); + }, [text]); + const label = copied ? t("PanelCopied") : t("ActionCopyError"); + return ( + + ); +} + +/** Login-method fallbacks that name the source, not a plan; Mac shows no plan for them. */ +const SOURCE_ONLY_PLANS = new Set(["claude (oauth)", "claude (cli)"]); + +/** The plan text shown on the right of the header's second line. */ +export function displayPlanName(planName: string | null | undefined, t: Translate): string | null { + const trimmed = planName?.trim(); + if (!trimmed) return null; + const normalized = trimmed.toLowerCase(); + if (normalized === "default_claude_ai") return t("ProviderPlanClaudeAi"); + if (SOURCE_ONLY_PLANS.has(normalized)) return null; + return trimmed; +} + +/** + * Upstream subtitle (`MenuCardView.swift:1077-1102`) without the error case: + * "Refreshing…", else "Updated …", else "Not fetched yet". + */ +export function headerSubtitle( + updatedAt: string | null | undefined, + isRefreshing: boolean, + t: Translate, + nowMs: number = Date.now(), +): string { + if (isRefreshing) return t("UsageSpendRefreshing"); + const raw = updatedAt?.trim(); + if (!raw) return t("ProviderNotFetchedYetTitle"); + const parsed = Date.parse(raw); + return Number.isNaN(parsed) ? raw : formatRelativeUpdated(parsed, t, nowMs); +} + +/** + * Halves for CSS middle truncation (upstream `.truncationMode(.middle)`): the + * head shrinks with an ellipsis, the tail keeps the end of the text visible. + */ +export function splitForMiddleTruncation(text: string): [string, string] { + const cut = Math.ceil(text.length / 2); + return [text.slice(0, cut), text.slice(cut)]; +} + +/** + * Card header, following upstream `MenuCardView.swift:325-410`: + * line 1 is the name (left) and the email (right, middle-truncated); + * line 2 is the subtitle or the red error with a copy button, and the plan. + */ +export default function CardHeader({ + name, + email, + plan, + error, + updatedAt, + isRefreshing, +}: { + name: string; + email: string | null; + plan: string | null; + error: string | null; + updatedAt: string | null | undefined; + isRefreshing: boolean; +}) { + const { t } = useLocale(); + return ( +
    +
    + {name} + {email && ( + + + {splitForMiddleTruncation(email)[0]} + + + {splitForMiddleTruncation(email)[1]} + + + )} +
    +
    + {error ? ( + <> + + {error} + + + + ) : ( + + {headerSubtitle(updatedAt, isRefreshing, t)} + + )} + {plan && {plan}} +
    +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/CardMetricRow.tsx b/apps/desktop-tauri/src/components/card/CardMetricRow.tsx new file mode 100644 index 0000000000..ff343f39e2 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/CardMetricRow.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import type { RateWindowSnapshot } from "../../types/bridge"; +import { useLocale } from "../../hooks/useLocale"; +import { + useFormattedResetTime, + type ResetTimeFormatMode, +} from "../../hooks/useFormattedResetTime"; +import { isMonthlyLimitBlockActive } from "../../lib/monthlyLimitBlock"; +import { windowDetailText } from "../../lib/usageWindows"; +import { localizeProviderText } from "../../lib/providerText"; +import { + metricResetText, + metricRowPresentation, + type MetricLane, + type UsageThresholdSettings, +} from "../../lib/metricRowModel"; +import UsageProgressBar from "../UsageProgressBar"; + +export interface MetricEntry { + id: string; + label: string; + snap: RateWindowSnapshot; + lane: MetricLane; + resetFormatMode?: ResetTimeFormatMode; +} + +export interface MetricRowDisplay { + resetTimeRelative: boolean; + showResetWhenExhausted?: boolean; + showPace?: boolean; + showAsUsed?: boolean; + compactOverview?: boolean; + monthlyLimitBlockNow?: number; + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; +} + +function useCountdownNow(ticking: boolean): number { + const [, setTick] = useState(0); + useEffect(() => { + if (!ticking) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 30_000); + return () => window.clearInterval(id); + }, [ticking]); + return Date.now(); +} + +/** + * One rate window (upstream `MenuCardMetricRow.swift`): the title with the + * percent and the reset on one line, the bar, then the pace meta line. + * Budgets, the pace chart and the session forecast live in Settings. + */ +export default function CardMetricRow({ + title, + snap, + lane, + providerId, + display, + resetFormatMode, +}: { + title: string; + snap: RateWindowSnapshot; + lane: MetricLane; + providerId: string; + display: MetricRowDisplay; + resetFormatMode?: ResetTimeFormatMode; +}) { + const { t } = useLocale(); + const { + resetTimeRelative, + showResetWhenExhausted = false, + showPace = true, + showAsUsed = false, + compactOverview = false, + monthlyLimitBlockNow, + usageThresholds = null, + weeklyProgressWorkDays = null, + } = display; + // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) + // blocks this window until the pool resets. Raw percentages stay untouched. + const blocked = isMonthlyLimitBlockActive( + snap.monthlyLimitBlock, + monthlyLimitBlockNow ?? Date.now(), + ); + const isInformational = snap.isInformational === true; + const informationalReset = useFormattedResetTime( + isInformational && !blocked ? snap.resetsAt : null, + null, + resetTimeRelative, + resetFormatMode ?? "reset", + ); + const now = useCountdownNow(!isInformational && !blocked && snap.resetsAt != null); + const detailText = localizeProviderText(windowDetailText(snap), t) || null; + if (blocked) { + // Upstream `MetricRow` status layout: title plus one secondary status line. + return ( +
    + {title} + {t("PanelBlockedByMonthlyLimit")} +
    + ); + } + if (isInformational) { + const infoPrimary = + localizeProviderText(snap.resetDescription?.trim(), t) || informationalReset || "—"; + return ( +
    + {title} +
    + {infoPrimary} + {!compactOverview && + snap.resetDescription?.trim() && + informationalReset && + informationalReset !== infoPrimary && ( + {informationalReset} + )} +
    + {!compactOverview && detailText && ( +
    {detailText}
    + )} +
    + ); + } + const row = metricRowPresentation( + { + snap, + lane, + providerId, + resetText: metricResetText(snap, resetTimeRelative, now, t), + compact: compactOverview, + showAsUsed, + showResetWhenExhausted, + paceEnabled: showPace, + usageThresholds, + weeklyProgressWorkDays, + now, + }, + t, + ); + return ( +
    +
    + + {title} + {row.percentText && ( + <> + {" "} + {row.percentText} + + )} + + {row.resetText && {row.resetText}} +
    + + {row.metaText &&
    {row.metaText}
    } + {!compactOverview && detailText && ( +
    {detailText}
    + )} + {!compactOverview && snap.isExhausted && ( +
    {t("DetailWindowExhausted")}
    + )} +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/DetailSections.tsx b/apps/desktop-tauri/src/components/card/DetailSections.tsx new file mode 100644 index 0000000000..6e7adb3c09 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/DetailSections.tsx @@ -0,0 +1,137 @@ +import { Fragment } from "react"; +import type { ProviderDisplayDetail, ProviderInventoryItem } from "../../types/bridge"; +import { useLocale } from "../../hooks/useLocale"; +import { useFormattedResetTime } from "../../hooks/useFormattedResetTime"; +import { groupProviderDisplayDetails } from "../ProviderDisplayRow"; +import { localizeProviderText } from "../../lib/providerText"; +import { localizeProviderLabel } from "../../lib/windowLabels"; +import { isDetailSectionVisible } from "../../lib/usageItemVisibility"; + +function progressPercent(detail: ProviderDisplayDetail): number | null { + const progress = detail.progress; + if ( + !progress || + !Number.isFinite(progress.used) || + !Number.isFinite(progress.total) || + progress.total <= 0 + ) { + return null; + } + return Math.max(0, Math.min(100, (progress.used / progress.total) * 100)); +} + +function KeyValueRow({ + label, + value, + secondary, + percent, +}: { + label: string; + value: string; + secondary: string | null; + percent: number | null; +}) { + return ( +
    +
    + {label} + + {value} + {secondary && {secondary}} + +
    + {percent != null && ( +
    +
    +
    + )} +
    + ); +} + +function InventoryRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const { t } = useLocale(); + const expiry = useFormattedResetTime(item.nextExpiresAt, null, resetTimeRelative, "expires"); + return ( + + ); +} + +/** + * Key-value sections (upstream `ProviderDetailSectionsContent.swift`): an + * uppercase caption title, then rows with the label on the left and the + * value, plus an optional secondary value, on the right. + */ +export default function DetailSections({ + details, + inventory, + hiddenUsageItemIds, + resetTimeRelative, +}: { + details: ProviderDisplayDetail[]; + inventory: ProviderInventoryItem[]; + hiddenUsageItemIds: string[] | null | undefined; + resetTimeRelative: boolean; +}) { + const { t } = useLocale(); + const groups = groupProviderDisplayDetails(details) + .map((group) => ({ + ...group, + rows: group.rows.filter((detail) => isDetailSectionVisible(hiddenUsageItemIds ?? undefined, detail.title)), + })) + .filter((group) => group.rows.length > 0); + if (inventory.length === 0 && groups.length === 0) return null; + + return ( +
    + {inventory.length > 0 && ( +
    + {inventory.map((item) => ( + + ))} +
    + )} + {groups.map((group, index) => ( + + {(index > 0 || inventory.length > 0) &&
    } +
    + {group.title && ( +
    + {localizeProviderLabel(group.title, t)} +
    + )} + {group.rows.map((detail, rowIndex) => ( + + ))} +
    + + ))} +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx b/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx new file mode 100644 index 0000000000..b6f072bd78 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/ExtraUsageBlock.tsx @@ -0,0 +1,80 @@ +import type { CostSnapshotBridge, CostSummaryDisplayStyle } from "../../types/bridge"; +import { useLocale } from "../../hooks/useLocale"; +import { useFormattedResetTime } from "../../hooks/useFormattedResetTime"; +import { cardCostSection } from "../../lib/cardCost"; + +/** + * Extra usage and cost block (upstream `MenuCardView+Costs.swift`): the title + * in body-medium, the bar in the provider color, then the spend line on the + * left and the percent on the right. A balance-only cost is one inline row. + */ +export default function ExtraUsageBlock({ + providerId, + cost, + style, + resetTimeRelative, +}: { + providerId: string; + cost: CostSnapshotBridge; + style: CostSummaryDisplayStyle | undefined; + resetTimeRelative: boolean; +}) { + const { t } = useLocale(); + const section = cardCostSection(cost, style, t); + const resetText = useFormattedResetTime( + section && !section.inline && style !== "compact" ? cost.resetsAt : null, + null, + resetTimeRelative, + ); + if (!section) return null; + + const monthlySpend = + providerId === "mistral" + ? `${t("MistralMonthlySpend")}: ${ + cost.currencySymbol ? `${cost.currencySymbol}${cost.used.toFixed(2)}` : cost.formattedUsed + }` + : null; + + if (section.inline) { + return ( +
    +
    + {section.title} + {section.spendLine} +
    + {monthlySpend && {monthlySpend}} +
    + ); + } + + return ( +
    + {section.title} + {section.percentUsed != null && ( +
    +
    +
    + )} +
    + {section.spendLine} + {section.percentLine && ( + {section.percentLine} + )} +
    + {section.balanceLine && {section.balanceLine}} + {resetText && ( + + {t("DetailCostResets")}: {resetText} + + )} + {monthlySpend && {monthlySpend}} +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/InlineDashboard.test.tsx b/apps/desktop-tauri/src/components/card/InlineDashboard.test.tsx new file mode 100644 index 0000000000..7fdba8534a --- /dev/null +++ b/apps/desktop-tauri/src/components/card/InlineDashboard.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => + ({ PanelRecentWindows: "Recent windows", PanelThirtyDayCostHistogram: "Daily cost" })[key] ?? + key, + language: "english", + }), +})); + +import InlineDashboard, { barHeight, barOpacity } from "./InlineDashboard"; +import type { InlineDashboardModel } from "../../lib/inlineDashboard"; + +const model: InlineDashboardModel = { + kpis: [ + { title: "Today", value: "$0.80", emphasis: true }, + { title: "30d cost", value: "$25.42", emphasis: false }, + ], + points: [ + { date: "2026-10-09", value: 2, title: "2026-10-09: $2.00 · 10K tokens" }, + { date: "2026-10-10", value: null, title: "2026-10-10" }, + { date: "2026-10-11", value: 8, title: "2026-10-11: $8.00 · 40K tokens" }, + ], + maxValue: 8, + scaleLabel: "$8", + windows: [ + { + id: "0", + title: "Current window", + range: "Oct 6, 9:24 AM – Oct 13, 9:24 AM", + value: "$2.65 · 1.8M", + note: null, + }, + ], + detailLines: ["Top model: gpt-5", "Estimated from local logs"], +}; + +describe("bar geometry", () => { + it("draws missing and zero values as a 1px line", () => { + expect(barHeight(null, 8)).toBe(1); + expect(barHeight(0, 8)).toBe(1); + expect(barHeight(3, 0)).toBe(1); + }); + + it("scales bars to the 43px left under the scale line of the 58px chart, with a 3px floor", () => { + expect(barHeight(8, 8)).toBe(43); + expect(barHeight(4, 8)).toBe(21.5); + expect(barHeight(0.01, 8)).toBe(3); + }); + + it("ramps the fill opacity from 0.42 + 0.18 × 0.58 to 1", () => { + expect(barOpacity(null, 8)).toBe(0); + expect(barOpacity(0.01, 8)).toBe(0.524); + expect(barOpacity(8, 8)).toBe(1); + }); +}); + +describe("InlineDashboard", () => { + it("renders the KPIs, the chart, the recent windows and the detail lines", () => { + const { container } = render(); + expect(screen.getByText("Today")).toHaveClass("card-dash__kpi-title"); + expect(screen.getByText("$0.80")).toHaveClass("card-dash__kpi-value--emphasis"); + expect(screen.getByText("$25.42")).not.toHaveClass("card-dash__kpi-value--emphasis"); + expect(screen.getByRole("img", { name: "Daily cost" })).toBeInTheDocument(); + expect(container.querySelectorAll(".card-dash__bar")).toHaveLength(3); + expect(screen.getByText("Recent windows")).toBeInTheDocument(); + expect(screen.getByText("$2.65 · 1.8M")).toBeInTheDocument(); + expect(screen.getByText("Oct 6, 9:24 AM – Oct 13, 9:24 AM")).toBeInTheDocument(); + expect(screen.getByText("Top model: gpt-5")).toBeInTheDocument(); + }); + + it("swaps the scale label for the hovered day", () => { + const { container } = render(); + const scale = container.querySelector(".card-dash__scale")!; + expect(scale).toHaveTextContent("$8"); + const bars = container.querySelectorAll(".card-dash__bar"); + fireEvent.mouseEnter(bars[2]); + expect(scale).toHaveTextContent("2026-10-11: $8.00 · 40K tokens"); + expect(bars[2]).toHaveClass("card-dash__bar--selected"); + fireEvent.mouseLeave(container.querySelector(".card-dash__chart")!); + expect(scale).toHaveTextContent("$8"); + }); + + it("omits the chart and windows when there are none", () => { + const { container } = render( + , + ); + expect(container.querySelector(".card-dash__chart")).toBeNull(); + expect(container.querySelector(".card-dash__windows")).toBeNull(); + expect(container.querySelector(".card-dash__details")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/card/InlineDashboard.tsx b/apps/desktop-tauri/src/components/card/InlineDashboard.tsx new file mode 100644 index 0000000000..d4595e2396 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/InlineDashboard.tsx @@ -0,0 +1,121 @@ +import { useState, type CSSProperties } from "react"; +import { useLocale } from "../../hooks/useLocale"; +import type { DashboardPoint, InlineDashboardModel } from "../../lib/inlineDashboard"; + +/** + * Upstream frames the chart at 58 px: a 13 px caption2 scale line, a 2 px gap, + * then the bars, so a full bar is 43 px tall. + */ +const BAR_AREA_HEIGHT = 58 - 13 - 2; + +/** Upstream `MiniUsageBars.height`: 1 px for no data, otherwise at least 3 px. */ +export function barHeight(value: number | null, maxValue: number): number { + if (value == null || maxValue <= 0) return 1; + const ratio = Math.min(1, value / maxValue); + if (ratio <= 0) return 1; + return Math.max(3, ratio * BAR_AREA_HEIGHT); +} + +/** Upstream `MiniUsageBars.fill`: provider color at 0.42 + ratio × 0.58, ratio ≥ 0.18. */ +export function barOpacity(value: number | null, maxValue: number): number { + if (value == null) return 0; + const ratio = Math.max(0.18, maxValue > 0 ? Math.min(1, value / maxValue) : 0); + return Math.round((0.42 + ratio * 0.58) * 1000) / 1000; +} + +function UsageBars({ + points, + maxValue, + scaleLabel, +}: { + points: DashboardPoint[]; + maxValue: number; + scaleLabel: string | null; +}) { + const { t } = useLocale(); + const [hovered, setHovered] = useState(null); + const hoverTitle = hovered != null ? points[hovered]?.title ?? null : null; + const style = { "--bar-count": points.length } as CSSProperties; + return ( +
    setHovered(null)}> +
    + {hoverTitle ?? scaleLabel ?? " "} +
    +
    + {points.map((point, index) => ( + setHovered(index)} + style={{ + height: `${barHeight(point.value, maxValue)}px`, + opacity: barOpacity(point.value, maxValue), + }} + /> + ))} +
    +
    + ); +} + +/** + * The card's inline usage dashboard (upstream `InlineUsageDashboardContent`): + * a two-column KPI grid, a 58 px daily cost chart, recent quota windows and + * short detail lines. + */ +export default function InlineDashboard({ model }: { model: InlineDashboardModel }) { + const { t } = useLocale(); + return ( +
    +
    + {model.kpis.map((kpi) => ( +
    + {kpi.title} + + {kpi.value} + +
    + ))} +
    + {model.points.length > 0 && ( + + )} + {model.windows.length > 0 && ( +
    + {t("PanelRecentWindows")} + {model.windows.map((window) => ( +
    +
    + {window.title} + {window.value} +
    + {window.note && {window.note}} + {window.range} +
    + ))} +
    + )} + {model.detailLines.length > 0 && ( +
    + {model.detailLines.map((line) => ( + + {line} + + ))} +
    + )} +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/WayfinderBlock.tsx b/apps/desktop-tauri/src/components/card/WayfinderBlock.tsx new file mode 100644 index 0000000000..73d55cce43 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/WayfinderBlock.tsx @@ -0,0 +1,58 @@ +import type { ProviderUsageSnapshot } from "../../types/bridge"; +import { useLocale } from "../../hooks/useLocale"; + +const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); + +function compactCount(value: number | null | undefined): string { + if (value == null || value <= 0) return "—"; + return compact.format(value); +} + +/** Wayfinder gateway telemetry, laid out like the inline dashboard KPIs. */ +export default function WayfinderBlock({ + usage, +}: { + usage: NonNullable; +}) { + const { t } = useLocale(); + const amount = (value: number) => + usage.priced ? `${value.toFixed(4)} ${usage.unit.toUpperCase()}` : "—"; + const kpis: [string, string][] = [ + [t("WayfinderGatewayStatus"), usage.gatewayStatus], + [t("WayfinderModels"), String(usage.modelCount)], + [t("WayfinderRequests"), compactCount(usage.requests)], + [t("WayfinderTokens"), compactCount(usage.tokens)], + ]; + const notes = [ + usage.offline ? t("WayfinderOffline") : null, + usage.dryRun ? t("WayfinderDryRun") : null, + usage.missingKeys.length > 0 + ? `${t("WayfinderMissingKeys")}: ${usage.missingKeys.join(", ")}` + : null, + ].filter((note): note is string => note != null); + + return ( +
    +
    + {kpis.map(([title, value]) => ( +
    + {title} + {value} +
    + ))} +
    + + {t("WayfinderSaved")}: {amount(usage.saved)} ({usage.savedPercent.toFixed(1)}%) + + {notes.length > 0 && ( +
    + {notes.map((note) => ( + + {note} + + ))} +
    + )} +
    + ); +} diff --git a/apps/desktop-tauri/src/components/card/describeCard.ts b/apps/desktop-tauri/src/components/card/describeCard.ts new file mode 100644 index 0000000000..abeaa5d230 --- /dev/null +++ b/apps/desktop-tauri/src/components/card/describeCard.ts @@ -0,0 +1,45 @@ +import type { + CostSummaryDisplayStyle, + ProviderChartData, + ProviderUsageSnapshot, +} from "../../types/bridge"; +import type { MetricEntry } from "./CardMetricRow"; + +export interface MenuCardPresence { + hasMetrics: boolean; + /** Inventory rows or provider key-value details. */ + hasDetailSections: boolean; + hasCost: boolean; + localUsage: ProviderChartData["localUsage"] | null; + wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; + hasDetails: boolean; +} + +/** + * Which card sections exist. A card with an error, or whose only content the + * compact Overview drops, renders its header alone with no divider. + */ +export function describeCard( + provider: ProviderUsageSnapshot, + chartData: ProviderChartData | null, + visibleMetrics: MetricEntry[], + costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", + compactOverview = false, +): MenuCardPresence { + const hasError = !!provider.error; + const hasMetrics = !hasError && visibleMetrics.length > 0; + const hasDetailSections = + !hasError && + ((provider.inventory?.length ?? 0) > 0 || (provider.displayDetails?.length ?? 0) > 0); + const hasCost = + !hasError && + !!provider.cost && + (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); + const localUsage = hasError ? null : chartData?.localUsage ?? null; + const wayfinderUsage = + !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; + const hasDetails = compactOverview + ? hasMetrics || !!wayfinderUsage + : hasMetrics || hasDetailSections || hasCost || !!localUsage || !!wayfinderUsage; + return { hasMetrics, hasDetailSections, hasCost, localUsage, wayfinderUsage, hasDetails }; +} diff --git a/apps/desktop-tauri/src/main.tsx b/apps/desktop-tauri/src/main.tsx index d5ac7f7057..d6900b771d 100644 --- a/apps/desktop-tauri/src/main.tsx +++ b/apps/desktop-tauri/src/main.tsx @@ -4,6 +4,7 @@ import App from "./App"; import "./styles.css"; import "./surfaces/settings/settings-layout.css"; import "./surfaces/menu-surface-layout.css"; +import "./surfaces/tray-card.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 6b9731e266..38d5e9fc7f 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -12,6 +12,9 @@ */ :root { color-scheme: dark; + /* Hover and fill tint for controls: white on dark surfaces, black on light + ones (light theme and the light tray panel). */ + --surface-tint-rgb: 255, 255, 255; --font-ui: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; font-family: var(--font-ui); text-rendering: optimizeLegibility; @@ -222,6 +225,7 @@ [data-theme="light"] { color-scheme: light; + --surface-tint-rgb: 0, 0, 0; --app-bg: #f5f5f7; --app-bg-gradient-top: transparent; @@ -827,9 +831,9 @@ html { .credential-btn { padding: 5px 12px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(var(--surface-tint-rgb), 0.1); border-radius: 6px; - background: rgba(255, 255, 255, 0.06); + background: rgba(var(--surface-tint-rgb), 0.06); color: var(--text-secondary); font-size: 0.78rem; font-weight: 600; @@ -839,9 +843,9 @@ html { } .credential-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); + background: rgba(var(--surface-tint-rgb), 0.1); color: var(--text-primary); - border-color: rgba(255, 255, 255, 0.18); + border-color: rgba(var(--surface-tint-rgb), 0.18); } .credential-btn:disabled { @@ -1509,7 +1513,7 @@ body:has(.tray-panel-reveal), } .update-banner__action:hover { - background: rgba(255, 255, 255, 0.06); + background: rgba(var(--surface-tint-rgb), 0.06); } /* ── Provider detail: org label ─────────────────────────────────────── */ @@ -3472,6 +3476,7 @@ html:has(.menu-surface--tray) { --pace-track-expected-fg: rgba(0, 122, 255, 0.45); --chart-cost-fg: #007aff; --chart-credits-fg: var(--chart-credits); + --surface-tint-rgb: 0, 0, 0; color-scheme: light; display: flex; @@ -3529,33 +3534,13 @@ html:has(.menu-surface--tray) { border-radius: 6px; padding: 4px 4px; } -.menu-surface--tray .menu-card__copy-btn:hover { - background: rgba(0, 0, 0, 0.06); -} .menu-surface--tray .provider-grid__item .provider-icon { filter: brightness(0); } -.menu-surface--tray .provider-grid__item--more .provider-grid__icon-overview { - background: rgba(0, 0, 0, 0.06); -} -.menu-surface--tray .provider-grid__item--more:hover .provider-grid__icon-overview { - background: rgba(0, 0, 0, 0.1); -} -.menu-surface--tray .update-banner__action:hover { - background: rgba(0, 0, 0, 0.06); -} .menu-surface--tray .update-banner--available, .menu-surface--tray .update-banner--ready { color: var(--provider-status-ok); } -.menu-surface--tray .credential-btn { - border-color: rgba(0, 0, 0, 0.12); - background: rgba(0, 0, 0, 0.05); -} -.menu-surface--tray .credential-btn:hover:not(:disabled) { - border-color: rgba(0, 0, 0, 0.18); - background: rgba(0, 0, 0, 0.08); -} /* Tray grid — flush with no background container, matching macOS */ .menu-surface--tray .provider-grid { background: transparent; @@ -3597,43 +3582,6 @@ html:has(.menu-surface--tray) { padding-bottom: var(--menu-card-section-y); } -/* Tray name-group — row layout like macOS: HStack { name — Spacer — email } */ -.menu-surface--tray .menu-card__name-group { - flex-direction: row; - align-items: baseline; - justify-content: space-between; - gap: 6px; - width: 100%; -} - -/* Tray name + email — macOS: .font(.headline) = 13pt semibold */ -.menu-surface--tray .menu-card__name { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.menu-surface--tray .menu-card__email { - flex: 0 1 auto; - text-align: right; - max-width: 55%; - font-size: 11px; - color: var(--text-secondary); -} - -/* Tray subtitle + error — match macOS .footnote=10pt */ -.menu-surface--tray .menu-card__subtitle, -.menu-surface--tray .menu-card__error-text { - font-size: 10px; -} -/* Tray "Updated just now" — secondary color like macOS */ -.menu-surface--tray .menu-card__subtitle:not(.menu-card__subtitle--error) { - color: var(--text-secondary); -} - /* Copy-to-clipboard button — matches macOS CopyIconButton (doc.on.doc, 18×18) */ .menu-card__copy-btn { display: inline-flex; @@ -3654,17 +3602,7 @@ html:has(.menu-surface--tray) { } .menu-card__copy-btn:hover { opacity: 1; - background: rgba(255, 255, 255, 0.08); -} - -/* Tray plan badge — plain text like macOS (not a pill) */ -.menu-surface--tray .menu-card__plan-badge { - background: none; - padding: 0; - border-radius: 0; - font-size: 10px; - font-weight: 400; - color: var(--text-secondary); + background: rgba(var(--surface-tint-rgb), 0.08); } /* Hide status dots in tray grid — macOS shows weekly indicators only on hover, @@ -3690,14 +3628,7 @@ html:has(.menu-surface--tray) { font-size: 13px; font-weight: 500; } -/* macOS: UsageProgressBar .frame(height: 6) */ -.menu-surface--tray .menu-metric__bar { - height: 6px; - border-radius: 3px; - width: 100%; - box-sizing: border-box; - max-width: 100%; -} + .menu-surface--tray .menu-metric__pct, .menu-surface--tray .menu-metric__reset, .menu-surface--tray .menu-metric__meta, @@ -3705,62 +3636,6 @@ html:has(.menu-surface--tray) { .menu-surface--tray .menu-metric__detail { font-size: 10px; } -/* Secondary charts and pace are available inside the Usage details disclosure. */ -.menu-surface--tray .menu-card__local-grid { - gap: 9px 20px; -} -.menu-surface--tray .menu-card__local-usage { - padding-top: 2px; -} -.menu-surface--tray .menu-card__local-note { - font-size: 10px; - line-height: 1.25; -} - -.menu-card__local-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px 28px; -} -.menu-card__local-grid > div, -.menu-card__local-note { - display: flex; - flex-direction: column; - gap: 2px; -} -.menu-card__local-label, -.menu-card__local-note { - font-size: 11px; - color: var(--text-secondary); -} -.menu-card__local-grid strong { - font-size: 13px; - line-height: 1.15; - color: var(--text-primary); - font-variant-numeric: tabular-nums; -} -.menu-card__local-chart { - display: flex; - align-items: flex-end; - gap: 5px; - height: 68px; - margin-top: 2px; -} -.menu-card__local-chart span { - width: 8px; - border-radius: 2px 2px 0 0; - background: #bf8337; -} -.menu-card__local-note strong { - font-size: 11px; - color: var(--text-secondary); -} -.menu-card__local-note span { - overflow: visible; - text-overflow: initial; - white-space: normal; - line-height: 1.3; -} /* ── Provider card — direct mirror of UsageMenuCardView ─────────────── */ @@ -3786,105 +3661,12 @@ html:has(.menu-surface--tray) { /* ── 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__usage { display: flex; align-items: center; @@ -3931,202 +3713,11 @@ html:has(.menu-surface--tray) { background: var(--usage-bar-exhausted, var(--accent)); } -.codex-menu-accounts__switch { - flex-shrink: 0; - font-size: 0.68rem; - font-weight: 600; - padding: 2px 6px; - white-space: nowrap; - 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__row-actions { - display: flex; - flex-direction: row; - align-items: center; - flex-shrink: 0; - gap: 4px; -} - -.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; - flex-direction: column; - gap: var(--menu-card-header-line-gap); - min-width: 0; - max-width: 100%; - overflow: hidden; -} - -.menu-card__title-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--menu-card-header-column-gap); -} - -.menu-card__refresh-btn { - flex-shrink: 0; - white-space: nowrap; -} - -/* Provider brand badge + display name cluster. Tauri-only addition that - gives each card a recognizable provider mark before the name. */ -.menu-card__name-group { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - flex: 1 1 auto; -} - -.menu-card__provider-icon { - flex-shrink: 0; -} - -/* providerName: Font.headline + .semibold — use 500 on Windows for parity */ -.menu-card__name { - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* email: Font.subheadline secondary ≈ 12px */ -.menu-card__email { - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 60%; - text-align: right; -} - -.menu-card__subtitle-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--menu-card-header-column-gap); -} - -/* When subtitle-row contains error text, align to top (macOS: .top) */ -.menu-card__subtitle-row:has(.menu-card__subtitle--error) { - align-items: flex-start; - min-width: 0; -} - -/* Error block — simple block layout that respects parent width for text wrapping. - Avoids the flex sizing issues that cause text to overflow the panel. */ -.menu-card__error-block { - display: block; - position: relative; - overflow: clip; - max-width: 100%; - box-sizing: border-box; - padding: 4px 0 2px; -} -.menu-card__error-text { - display: block; - font-size: 11px; - color: var(--provider-status-error); - line-height: 1.4; - word-break: break-word; - overflow-wrap: anywhere; - white-space: normal; - padding-right: 20px; /* space for copy button */ - box-sizing: border-box; -} -.menu-card__error-block .menu-card__copy-btn { - position: absolute; - top: 0; - right: 0; -} - -/* subtitle: Font.footnote secondary ≈ 11px */ -.menu-card__subtitle { - flex: 1; - min-width: 0; - font-size: 11px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -/* Error subtitle must wrap — override base truncation. - width:0 + flex-grow:1 forces the flex item to respect container bounds - instead of sizing to content width and overflowing. */ -.menu-card__subtitle.menu-card__subtitle--error { - width: 0; - flex-grow: 1; - white-space: normal; - overflow: hidden; - text-overflow: unset; - overflow-wrap: anywhere; - word-break: break-word; -} - -/* plan: Font.footnote secondary ≈ 11px, no special weight upstream */ -.menu-card__plan { - font-size: 11px; - color: var(--text-secondary); - white-space: nowrap; -} - -.menu-card__error { - padding: 6px 8px; - border-radius: 6px; - background: var(--surface-elevated); - color: var(--provider-status-error); - font-size: 11px; - line-height: 1.35; -} - /* SwiftUI Divider() — single hairline using the system separator. */ .menu-card__divider { margin: 4px 0; } -.menu-card__pricing-status { - display: flex; - flex-direction: column; - gap: 3px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--divider, var(--provider-row-divider, rgba(128, 128, 128, 0.18))); - color: var(--text-secondary); - font-size: 10px; - line-height: 1.35; - font-variant-numeric: tabular-nums; -} - -.menu-card__pricing-status strong { - color: var(--text-primary); - font-size: 11px; - font-weight: 600; -} - -.menu-card__pricing-status small { - margin-top: 2px; - color: var(--text-tertiary, var(--text-secondary)); - line-height: 1.4; -} - .menu-card__content { display: flex; flex-direction: column; @@ -4171,21 +3762,6 @@ html:has(.menu-surface--tray) { max-width: 100%; } -/* UsageProgressBar — Capsule track + Capsule fill, .frame(height: 6) */ -.menu-metric__bar { - height: 6px; - border-radius: 3px; - background: var(--usage-bar-track); - overflow: hidden; -} - -.menu-metric__bar-fill { - height: 100%; - border-radius: 3px; - background: var(--provider-accent, var(--usage-bar-normal)); - transition: width 0.3s ease; -} - .menu-metric__progress { display: block; width: 100%; @@ -4245,17 +3821,6 @@ html:has(.menu-surface--tray) { flex: 0 0 auto; } -/* A live forecast is its own full-width row. Release the base percent label's - no-shrink rule so refreshed text is constrained by the row and tail-truncated - inside the fixed tray width instead of painting past it. */ -.menu-metric__forecast .menu-metric__pct { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - /* Detail-backed window description (for example spend amounts): its own secondary line under the metric row, never reset wording. */ .menu-metric__detail { @@ -4301,48 +3866,6 @@ html:has(.menu-surface--tray) { white-space: nowrap; } -.menu-metric__budget { - display: flex; - flex-direction: column; - gap: 6px; -} - -.menu-metric__budget-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: var(--text-primary); - font: inherit; - font-size: 10px; - cursor: pointer; -} - -.menu-metric__budget-header span:last-child { - color: var(--text-secondary); -} - -.menu-metric__budget-pills { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.menu-metric__budget-pill { - padding: 2px 6px; - border: 1px solid var(--usage-bar-track); - border-radius: 999px; - background: var(--surface-elevated); - color: var(--text-secondary); - font-size: 9px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - .pace-details-chart { display: flex; flex-direction: column; @@ -4413,84 +3936,8 @@ html:has(.menu-surface--tray) { /* ── Cost section (upstream `tokenUsage` block) ─────────────────────── */ -.menu-card__cost-line { - font-size: 11px; /* upstream: .font(.footnote) */ - color: var(--text-primary); - font-variant-numeric: tabular-nums; -} - -.menu-card__cost-line--muted { - color: var(--text-secondary); -} - /* ── Pace section (Tauri-only; sits below cost in the same card) ────── */ -.menu-card__pace { - gap: 4px; -} - -.menu-card__pace-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; -} - -.menu-card__pace-label { - font-size: 11px; - font-weight: 600; -} - -.menu-card__pace-label[data-pace="slow"] { color: var(--pace-slow-fg); } -.menu-card__pace-label[data-pace="steady"] { color: var(--pace-steady-fg); } -.menu-card__pace-label[data-pace="racing"] { color: var(--pace-racing-fg); } -.menu-card__pace-label[data-pace="burning"] { color: var(--pace-burning-fg); } - -.menu-card__pace-bars { - display: flex; - flex-direction: column; - gap: 3px; -} - -.menu-card__pace-track { - height: 5px; - border-radius: 3px; - background: var(--usage-bar-track); - overflow: hidden; -} - -.menu-card__pace-fill { - height: 100%; - border-radius: 3px; - background: var(--usage-bar-normal); - transition: width 0.3s ease; -} - -.menu-card__pace-fill[data-pace="slow"] { background: var(--pace-slow-fg); } -.menu-card__pace-fill[data-pace="steady"] { background: var(--pace-steady-fg); } -.menu-card__pace-fill[data-pace="racing"] { background: var(--pace-racing-fg); } -.menu-card__pace-fill[data-pace="burning"] { background: var(--pace-burning-fg); } - -.menu-card__pace-fill--expected { - background: var(--pace-track-expected-fg) !important; -} - -.menu-card__pace-eta { - font-size: 11px; - font-weight: 500; - color: var(--pace-racing-fg); -} - -.menu-card__pace-ok { - font-size: 11px; - font-weight: 500; - color: var(--pace-steady-fg); -} - -.menu-card__charts { - gap: 10px; -} - /* ════════════════════════════════════════════════════════════════════ * macOS PARITY OVERLAY (last cascade — wins over earlier rules) * @@ -4822,22 +4269,6 @@ html:has(.menu-surface--tray) { color: var(--text-primary); } -/* ── Menu card / metric row tightening ────────────────────────────── */ -.menu-card__name { - font-size: var(--font-headline); - /* upstream .semibold (600) renders too heavy on Windows; 500 is visually closer */ - font-weight: 500; - letter-spacing: -0.005em; -} -.menu-card__email, -.menu-card__plan, -.menu-card__subtitle { - font-size: var(--font-caption); - font-weight: 400; -} -.menu-card__email { - color: var(--text-secondary); -} .menu-card__group-title, .menu-metric__title { font-size: var(--font-body); @@ -4922,19 +4353,6 @@ html:has(.menu-surface--tray) { font-size: var(--font-caption); font-variant-numeric: tabular-nums; } -.menu-metric__bar, -.menu-card__pace-track { - height: var(--bar-height); - border-radius: var(--bar-radius); - background: var(--usage-bar-track); - overflow: hidden; -} -.menu-metric__bar-fill, -.menu-card__pace-fill { - height: 100%; - border-radius: var(--bar-radius); - transition: width 0.3s ease; -} /* Upstream UsageProgressBar uses the provider brand color (which the chart palette covers) but the menu defaults to a green-when-healthy @@ -4958,12 +4376,6 @@ html:has(.menu-surface--tray) { .menu-metric__detail { font-size: var(--font-caption); } -.menu-card__cost-line, -.menu-card__pace-label, -.menu-card__pace-eta, -.menu-card__pace-ok { - font-size: var(--font-caption); -} /* ── Card stack: spacing between cards (no visible divider) ───── */ .menu-stack__sep { @@ -5623,17 +5035,7 @@ html:has(.menu-surface--tray) { text-align: center; } -/* ── macOS parity: inline error subtitle, divider spacing, footer icon - opacity, and card bottom padding (final overlay) ─────────────────── */ -.menu-card__subtitle--error { - color: var(--provider-status-error); - line-height: 1.3; - white-space: normal; - overflow: hidden; - text-overflow: unset; - font-size: 11px; -} - +/* ── macOS parity: divider spacing and card bottom padding (final overlay) ── */ .menu-card__divider { margin: 4px 0; } @@ -5644,38 +5046,6 @@ html:has(.menu-surface--tray) { /* ── Provider tab status dots removed (replaced by .provider-grid__dot) */ -/* ── Plan badge pill ────────────────────────────────────────────────── */ -.menu-card__plan-badge { - display: inline-flex; - align-items: center; - padding: 1px 8px; - font-size: 11px; - font-weight: 600; - border-radius: 10px; - background: rgba(255, 255, 255, 0.12); - color: var(--text-secondary); - white-space: nowrap; - flex-shrink: 0; - line-height: 1.4; -} -[data-theme="light"] .menu-card__plan-badge { - background: rgba(0, 0, 0, 0.08); -} - -/* ── Card header: name + badge on same row ──────────────────────────── */ -.menu-card__title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--menu-card-header-column-gap); -} -.menu-card__name-group { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - /* ── Windows QA polish: stable provider grid and compact heights ─── */ .provider-grid { grid-template-columns: repeat(8, 34px); @@ -5761,16 +5131,15 @@ html:has(.menu-surface--tray) { width: 17px; height: 17px; border-radius: 4px; - background: rgba(255, 255, 255, 0.10); + background: rgba(var(--surface-tint-rgb), 0.1); font-size: 14px; font-weight: 600; } .provider-grid__item--more:hover .provider-grid__icon-overview { - background: rgba(255, 255, 255, 0.16); + background: rgba(var(--surface-tint-rgb), 0.16); } - .settings-tabs { gap: 4px; padding: 5px 12px; @@ -5928,17 +5297,11 @@ html:has(.menu-surface--tray) { color: inherit; } -/* ── Mistral monthly spend row (#2821, #2947) ──────────────────── */ -.menu-card__monthly-spend { - margin-top: 4px; - font-weight: 500; -} /* Shared Windows typography and control sizing. */ button, input, select, textarea { font-family: var(--font-ui); } - .select, .number-input, .text-input { min-height: 28px; } @@ -5946,27 +5309,8 @@ button, input, select, textarea { max-width: 240px; } -/* Account login actions follow each provider's account list. */ -.codex-menu-accounts__actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 6px; -} .codex-menu-accounts__usage { flex-wrap: wrap; white-space: normal; } -.codex-menu-accounts__action { - min-height: 28px; - padding: 3px 10px; - border: 1px solid var(--panel-border); - border-radius: 5px; - background: transparent; - color: var(--text-primary); - font: inherit; - font-size: 11px; - cursor: pointer; -} -.codex-menu-accounts__action:disabled { opacity: 0.5; cursor: default; } diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx index a1d83f9f69..591b3ecf36 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -60,7 +60,7 @@ vi.mock("../lib/tauri", () => tauriMocks); vi.mock("@tauri-apps/api/event", () => eventMocks); vi.mock("@tauri-apps/api/window", () => windowMocks); -import TrayPanel from "./TrayPanel"; +import TrayPanel, { splitSpendTitle } from "./TrayPanel"; import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; import type { ProviderUsageSnapshot, SettingsSnapshot } from "../types/bridge"; @@ -189,10 +189,29 @@ describe("TrayPanel History window", () => { , ); - expect(await screen.findByText("OverviewSpendPeriodTitle")).toBeInTheDocument(); - expect(screen.getByText("$9.00")).toBeInTheDocument(); + expect(await screen.findByText("OverviewSpendPeriodTitle")).toHaveClass( + "overview-spend__heading", + ); + expect(screen.getByText("$9.00")).toHaveClass("overview-spend__total"); expect(screen.queryByText("$2.00")).not.toBeInTheDocument(); // The scan carries no explicit window: the backend resolves the saved one. expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith(); }); }); + +describe("splitSpendTitle", () => { + it("separates the heading from the secondary range tail", () => { + expect(splitSpendTitle("Usage & Spend · {}", "30d")).toEqual({ + heading: "Usage & Spend", + detail: "· 30d", + }); + expect(splitSpendTitle("{} の使用量", "30d")).toEqual({ + heading: "30d の使用量", + detail: null, + }); + expect(splitSpendTitle("OverviewSpendPeriodTitle", "30d")).toEqual({ + heading: "OverviewSpendPeriodTitle", + detail: null, + }); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 3a14c67168..e1fb3ad751 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -607,7 +607,7 @@ describe("TrayPanel provider grid", () => { expect(await screen.findByText("CodexBar について (v0.70.0)")).toBeInTheDocument(); expect(screen.getByText("終了")).toBeInTheDocument(); expect(await screen.findByText("30日間のコスト")).toBeInTheDocument(); - expect(container.querySelector(".menu-card__subtitle")?.textContent).toContain("日前"); + expect(container.querySelector(".card-header__subtitle")?.textContent).toContain("日前"); expect(screen.getByText("最新トークン")).toBeInTheDocument(); expect(screen.getByText("トップモデル: gpt-5.5")).toBeInTheDocument(); expect(screen.getByText("ローカルログから推定")).toBeInTheDocument(); @@ -733,7 +733,7 @@ describe("TrayPanel provider grid", () => { "Gemini", ]); expect( - Array.from(container.querySelectorAll(".menu-card__name")).map( + Array.from(container.querySelectorAll(".card-header__name")).map( (node) => node.textContent, ), ).toEqual(["Codex", "Claude", "Cursor", "Factory", "Gemini"]); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index cf585c4b6c..66da64f9b0 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -158,6 +158,25 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { ); } +/** + * Splits "Usage & Spend · {}" into the semibold heading and the secondary + * "· range" tail, whatever the locale puts around the placeholder. + */ +export function splitSpendTitle( + template: string, + range: string, +): { heading: string; detail: string | null } { + const at = template.indexOf("{}"); + if (at < 0) return { heading: template, detail: null }; + const before = template.slice(0, at); + const after = template.slice(at + 2); + const dot = before.lastIndexOf("·"); + const cut = dot < 0 ? before.length : dot; + const heading = before.slice(0, cut).trim(); + const detail = `${before.slice(cut).trim()} ${range}${after}`.trim(); + return heading ? { heading, detail } : { heading: detail, detail: null }; +} + function OverviewSpendSummary({ providerIds, period, @@ -199,22 +218,34 @@ function OverviewSpendSummary({ if (aggregate.total == null && aggregate.considered === 0) return null; const partial = aggregate.included < aggregate.considered; + const { heading, detail } = splitSpendTitle( + t("OverviewSpendPeriodTitle"), + costPeriodShortLabel(summaryPeriod, t), + ); + const total = + aggregate.total == null + ? "—" + : `${partial ? "~" : ""}${new Intl.NumberFormat(undefined, { style: "currency", currency: target === "AUTO" ? "USD" : target, maximumFractionDigits: 2 }).format(aggregate.total)}`; + + // Upstream `OverviewSpendSummaryView`: headline, the title2 total, then captions. return ( -
    -
    - {title} - - {aggregate.total == null - ? "—" - : `${partial ? "~" : ""}${new Intl.NumberFormat(undefined, { style: "currency", currency: target === "AUTO" ? "USD" : target, maximumFractionDigits: 2 }).format(aggregate.total)}`} - +
    +
    + {heading} + {detail && ( + <> + {" "} + {detail} + + )}
    -
    + {total} +
    {t("OverviewSpendProviderCoverage") .replace("{}", String(aggregate.included)) .replace("{}", String(aggregate.considered))}{" "} · {t("OverviewSpendEstimate")}
    -
    +
    ); } diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index 275af2cd36..a3ad5e44c8 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -11,36 +11,8 @@ gap: 12px; } -.menu-surface--tray .menu-card__name-group { - flex-direction: row; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -.menu-surface--tray .menu-card__name { - font-size: 14px; - font-weight: 600; - letter-spacing: 0; -} - -.menu-surface--tray .menu-card__email { - max-width: 60%; - text-align: right; - font-size: 11px; -} - -.menu-surface--tray .menu-card__subtitle, -.menu-surface--tray .menu-card__plan-badge { - font-size: 11px; - font-weight: 400; - background: transparent; - padding: 0; -} - .menu-surface--tray .menu-card__content, -.menu-card__metrics, -.menu-card__more-content { +.menu-card__metrics { display: flex; flex-direction: column; gap: 16px; @@ -68,86 +40,6 @@ line-height: 1.4; } -.menu-metric__budget-header { - color: var(--text-secondary); - padding: 2px 0; - justify-content: flex-start; - gap: 6px; -} -.menu-metric__budget-header > span + span { - margin-left: auto; -} -.menu-metric__budget-header::before { - content: "›"; - margin-right: 4px; - transition: transform 120ms ease; -} -.menu-metric__budget-header[aria-expanded="true"]::before { - transform: rotate(90deg); -} -@media (prefers-reduced-motion: reduce) { - .menu-metric__budget-header::before { - transition: none; - } -} - -.codex-menu-accounts { - margin: 0; - padding: 0; - border: none; -} -.codex-menu-accounts__summary, -.menu-card__more > summary { - min-height: 28px; - font-size: 12px; - font-weight: 400; - text-transform: none; - letter-spacing: 0; - color: var(--text-secondary); - cursor: pointer; -} -.codex-menu-accounts__summary:hover, -.menu-card__more > summary:hover { - color: var(--text-primary); -} -.codex-menu-accounts__list { - margin-top: 4px; - gap: 4px; -} -.codex-menu-accounts__row { - min-height: 36px; - padding: 4px 0; -} -.codex-menu-accounts__email { - font-size: 12px; - flex-wrap: wrap; - overflow-wrap: anywhere; - white-space: normal; -} -.codex-menu-accounts__badge { - font-size: 10px; - font-weight: 500; - text-transform: none; - letter-spacing: 0; - color: var(--text-secondary); -} -.codex-menu-accounts__switch { - min-height: 28px; - padding: 3px 10px; - font-size: 11px; - font-weight: 500; -} -.menu-card__more { - border-top: 1px solid var(--panel-border); - padding-top: 8px; -} -.menu-card__more > summary { - line-height: 28px; -} -.menu-card__more-content { - padding-top: 12px; -} - .menu-surface--tray .menu-stack__item, .menu-surface--tray .menu-stack__item--selected { background: transparent; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 12f63f1c01..127792daf1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -33,6 +33,7 @@ import { UsageItemVisibilitySection } from "./sections/UsageItemVisibilitySectio import { AutoResumeSection } from "./sections/AutoResumeSection"; import { OptionalDetailsSection } from "./sections/OptionalDetailsSection"; import { PaceSection } from "./sections/PaceSection"; +import { UsageDetailsSection } from "./sections/UsageDetailsSection"; import { CostSection } from "./sections/CostSection"; import { QuickActionsSection } from "./sections/QuickActionsSection"; import { ChartsSection } from "./sections/charts/ChartsSection"; @@ -405,6 +406,9 @@ export function ProviderDetailPane({ } t={t} /> + ({ + getDeepSeekPricingStatus: vi.fn(), +})); +vi.mock("../../../../lib/tauri", async (importOriginal) => ({ + ...(await importOriginal()), + ...tauriMocks, +})); + +const STRINGS: Record = { + PanelUsageDetails: "Usage details", + PanelOnPaceBudget: "On-pace budget", + PanelNow: "now", + PanelOneHour: "1h", + PanelFiveHours: "5h", + PanelTodayBudget: "today", + PanelExpected: "Expected", + PanelActual: "Actual", + DeepSeekPricingTitle: "DeepSeek pricing", + DeepSeekPricingOffPeak: "Off-peak hours", + DeepSeekPricingCurrent: "Current local time:", + DeepSeekPricingNext: "Next transition:", + DeepSeekPricingEffective: "Effective local time:", + DeepSeekPricingAdvice: "Official schedule", +}; +vi.mock("../../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => STRINGS[key] ?? key, language: "english" }), +})); + +import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../../../../types/bridge"; +import { + formatSessionEquivalentEstimate, + UsageDetailsSection, +} from "./UsageDetailsSection"; + +const WEEK = 7 * 24 * 60; + +function window(usedPercent: number, windowMinutes: number | null, resetsAt: string | null) { + return { + usedPercent, + remainingPercent: 100 - usedPercent, + windowMinutes, + resetsAt, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + reserveWillLastToReset: false, + reserveEtaSeconds: null, + } as RateWindowSnapshot; +} + +function snapshot(overrides: Partial = {}): ProviderUsageSnapshot { + const primary = window(20, 5 * 60, null); + return { + providerId: "claude", + displayName: "Claude", + primary, + selectedMetric: primary, + primaryLabel: "Session", + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "oauth", + updatedAt: "2026-10-11T00:00:00Z", + error: null, + errorState: "unknown", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: null, + ...overrides, + } as ProviderUsageSnapshot; +} + +describe("formatSessionEquivalentEstimate", () => { + it("rounds to one decimal and pluralizes", () => { + const forecast = (n: number) => + ({ estimatedWindowsToExhaustWeekly: n }) as ProviderUsageSnapshot["sessionEquivalentForecast"]; + expect(formatSessionEquivalentEstimate(forecast(3.04))).toBe("Estimated: 3 session quotas left"); + expect(formatSessionEquivalentEstimate(forecast(0.5))).toBe("Estimated: 0.5 session quota left"); + expect(formatSessionEquivalentEstimate(null)).toBeNull(); + }); +}); + +describe("UsageDetailsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); + }); + + it("renders nothing without a live snapshot or any planning detail", () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + rerender(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the on-pace budget and projection chart for a weekly window", () => { + const resetsAt = new Date(Date.now() + 0.6 * 7 * 24 * 60 * 60 * 1000).toISOString(); + render( + , + ); + expect(screen.getByRole("heading", { name: "Usage details" })).toBeInTheDocument(); + expect(screen.getByText(/On-pace budget/)).toBeInTheDocument(); + expect(screen.getByText("now 20%")).toBeInTheDocument(); + expect(screen.getByText("1h 21%")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /PaceChartAriaLabel/i })).toBeInTheDocument(); + }); + + it("skips budgets for windows shorter than a week", () => { + const resetsAt = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the forecast and expected versus actual pace bars", () => { + const { container } = render( + , + ); + expect(screen.getByText("Estimated: 2.5 session quotas left")).toBeInTheDocument(); + expect(screen.getByText("+12.5%")).toBeInTheDocument(); + const fills = container.querySelectorAll(".provider-usage-details__pace-fill"); + expect(fills[0].style.width).toBe("40%"); + expect(fills[1].style.width).toBe("52.5%"); + }); + + it("drops pace detail for providers whose pace is not meaningful", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows DeepSeek pricing", async () => { + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue({ + period: "offPeak", + currentLocalTime: "2026-08-17 05:00:00 UTC", + nextTransitionLocalTime: "2026-08-17 06:00:00 UTC", + effectiveLocalTime: "2026-08-16 18:00:00 EDT", + }); + render(); + expect(await screen.findByText("DeepSeek pricing: Off-peak hours")).toBeInTheDocument(); + expect(screen.getByText("Next transition: 2026-08-17 06:00:00 UTC")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx new file mode 100644 index 0000000000..820a299cd9 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from "react"; +import type { + DeepSeekPricingStatus, + PaceSnapshot, + ProviderUsageSnapshot, + RateWindowSnapshot, + SessionEquivalentForecastSnapshot, +} from "../../../../types/bridge"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { useLocale } from "../../../../hooks/useLocale"; +import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; +import { DEEPSEEK_PRICING_EVENT } from "../../../../hooks/useDeepSeekPricingStatus"; +import { getDeepSeekPricingStatus } from "../../../../lib/tauri"; +import { getPaceBudget, type PaceBudget } from "../../../../lib/paceBudget"; +import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; +import { providerAllowsPace } from "../../../../lib/providerPace"; +import { localizeWindowLabel } from "../../../../lib/windowLabels"; +import { paceCategory } from "../../../tray/paceCategory"; +import PaceDetailsChart from "../../../../components/PaceDetailsChart"; +import QuotaBurndownChart from "../../../../components/QuotaBurndownChart"; + +const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60; + +/** Upstream session-quota estimate: "Estimated: {n} session quota(s) left". */ +export function formatSessionEquivalentEstimate( + forecast: SessionEquivalentForecastSnapshot | null | undefined, +): string | null { + if (!forecast) return null; + const raw = forecast.estimatedWindowsToExhaustWeekly; + if (!Number.isFinite(raw)) return null; + const rounded = Math.round(Math.min(Math.max(raw, 0), 1_000_000) * 10) / 10; + const display = + Number.isInteger(rounded) || Math.abs(rounded - Math.round(rounded)) < 1e-9 + ? String(Math.round(rounded)) + : rounded.toFixed(1); + const unit = rounded > 0 && rounded <= 1 ? "session quota" : "session quotas"; + return `Estimated: ${display} ${unit} left`; +} + +export function formatBudget(value: number): string { + return value < 10 ? value.toFixed(1).replace(/\.0$/, "") : Math.round(value).toString(); +} + +interface WeeklyBudget { + id: string; + label: string; + snap: RateWindowSnapshot; + budget: PaceBudget; +} + +type T = (key: LocaleKey) => string; + +/** Weekly (or longer) windows with an on-pace budget; blocked windows have none. */ +export function weeklyBudgets( + provider: ProviderUsageSnapshot, + t: T, + language: string, + monthlyLimitBlockNow: number, +): WeeklyBudget[] { + const windows: { id: string; label: string; snap: RateWindowSnapshot | null | undefined }[] = [ + { + id: "primary", + label: + localizeWindowLabel(provider.primaryLabel, t, language, provider.primary.windowMinutes) || + t("DetailWindowPrimary"), + snap: provider.providerId === "wayfinder" ? null : provider.primary, + }, + { + id: "secondary", + label: localizeWindowLabel(provider.secondaryLabel, t) || t("DetailWindowSecondary"), + snap: provider.secondary, + }, + { id: "model-specific", label: t("DetailWindowModelSpecific"), snap: provider.modelSpecific }, + { + id: "tertiary", + label: localizeWindowLabel(provider.tertiaryLabel, t) || t("DetailWindowTertiary"), + snap: provider.tertiary, + }, + ...(provider.extraRateWindows ?? []).map((extra) => ({ + id: `extra-${extra.id}`, + label: + localizeWindowLabel(extra.title, t, language, extra.window.windowMinutes, extra.id) || + extra.title, + snap: extra.window, + })), + ]; + const result: WeeklyBudget[] = []; + for (const window of windows) { + const snap = window.snap; + if (!snap || snap.isExhausted || snap.isInformational === true) continue; + if (snap.windowMinutes == null || snap.windowMinutes < WEEKLY_WINDOW_MINUTES) continue; + if (isMonthlyLimitBlockActive(snap.monthlyLimitBlock, monthlyLimitBlockNow)) continue; + const budget = getPaceBudget(snap); + if (budget) result.push({ id: window.id, label: window.label, snap, budget }); + } + return result; +} + +function PaceBars({ pace, t }: { pace: PaceSnapshot; t: T }) { + const category = paceCategory(pace.stage); + return ( +
    +
    + {pace.deltaPercent >= 0 ? "+" : ""} + {pace.deltaPercent.toFixed(1)}% +
    +
    + {t("PanelExpected")} +
    +
    +
    +
    +
    + {t("PanelActual")} +
    +
    +
    +
    +
    + ); +} + +function DeepSeekPricing({ t }: { t: T }) { + const [status, setStatus] = useState(null); + useEffect(() => { + let cancelled = false; + const onPricing = (event: Event) => + setStatus((event as CustomEvent).detail); + window.addEventListener(DEEPSEEK_PRICING_EVENT, onPricing); + void getDeepSeekPricingStatus() + .then((next) => { + if (!cancelled) setStatus(next); + }) + .catch(() => {}); + return () => { + cancelled = true; + window.removeEventListener(DEEPSEEK_PRICING_EVENT, onPricing); + }; + }, []); + if (!status) return null; + const period = + status.period === "peak" + ? "DeepSeekPricingPeak" + : status.period === "offPeak" + ? "DeepSeekPricingOffPeak" + : "DeepSeekPricingStandard"; + return ( +
    + + {t("DeepSeekPricingTitle")}: {t(period)} + + + {t("DeepSeekPricingCurrent")} {status.currentLocalTime} + + + {t("DeepSeekPricingNext")} {status.nextTransitionLocalTime ?? "—"} + + + {t("DeepSeekPricingEffective")} {status.effectiveLocalTime} + + {t("DeepSeekPricingAdvice")} +
    + ); +} + +/** + * Settings > Providers "Usage details": the planning detail that used to sit + * inside the tray card (on-pace budgets with the projection chart, the session + * forecast, expected-versus-actual pace bars, the quota burndown and DeepSeek + * pricing). The tray card keeps the upstream card anatomy only. + */ +export function UsageDetailsSection({ provider }: { provider: ProviderUsageSnapshot | null }) { + const { t, language } = useLocale(); + const monthlyLimitBlockNow = useMonthlyLimitBlockNow([ + provider?.primary.monthlyLimitBlock, + provider?.secondary?.monthlyLimitBlock, + provider?.modelSpecific?.monthlyLimitBlock, + provider?.tertiary?.monthlyLimitBlock, + ...(provider?.extraRateWindows ?? []).map((extra) => extra.window.monthlyLimitBlock), + provider?.pace?.monthlyLimitBlock, + ]); + if (!provider || provider.error) { + return provider?.providerId === "deepseek" ? ( +
    +

    {t("PanelUsageDetails")}

    + +
    + ) : null; + } + + const paceAllowed = providerAllowsPace(provider.providerId, provider.sourceLabel); + const budgets = paceAllowed ? weeklyBudgets(provider, t, language, monthlyLimitBlockNow) : []; + const forecast = paceAllowed + ? formatSessionEquivalentEstimate(provider.sessionEquivalentForecast) + : null; + const pace = + paceAllowed && + provider.pace && + !isMonthlyLimitBlockActive(provider.pace.monthlyLimitBlock, monthlyLimitBlockNow) + ? provider.pace + : null; + const burndown = + provider.quotaBurndown && provider.quotaBurndown.samples.length > 0 + ? provider.quotaBurndown + : null; + const isDeepSeek = provider.providerId === "deepseek"; + + if (budgets.length === 0 && !forecast && !pace && !burndown && !isDeepSeek) return null; + + return ( +
    +

    {t("PanelUsageDetails")}

    + {pace && } + {forecast &&
    {forecast}
    } + {budgets.map(({ id, label, snap, budget }) => ( +
    +
    + {label} · {t("PanelOnPaceBudget")} +
    +
    + {( + [ + [t("PanelNow"), budget.now], + [t("PanelOneHour"), budget.nextHour], + [t("PanelFiveHours"), budget.nextFiveHours], + [t("PanelTodayBudget"), budget.today], + ] as const + ).map(([name, value]) => ( + + {name} {formatBudget(value)}% + + ))} +
    + +
    + ))} + {burndown && } + {isDeepSeek && } +
    + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx index a9b69c4cd4..e20f9b2f7c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -180,3 +180,45 @@ describe("ChartsSection OpenAI API daily usage (upstream 0.66.0)", () => { expect(screen.queryByRole("listbox")).toBeNull(); }); }); + +describe("ChartsSection quota window history (moved from the tray card)", () => { + const quotaWindowHistory = { + providerId: "claude", + accountScope: null, + historyCoverageEstablished: true, + windows: [ + { + offset: 0, + start: "2026-09-14T12:00:00Z", + end: "2026-09-21T12:00:00Z", + totalTokens: 1234, + totalCostUsd: 1.25, + tokensAreComplete: true, + costIsComplete: true, + entryCount: 2, + boundariesAreEstimated: false, + }, + ], + }; + + it("lists recent quota windows under the chart tabs", async () => { + mockChart.mockResolvedValue(chartData({ providerId: "claude", quotaWindowHistory })); + render( key} />); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + }); + + it("shows the history alone when there is no chart series", async () => { + mockChart.mockResolvedValue( + chartData({ + providerId: "claude", + costHistory: [], + tokensHistory: [], + quotaWindowHistory, + }), + ); + render( key} />); + expect(await screen.findByText("$1.25")).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 952acdcba5..6448b534e0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -15,6 +15,7 @@ import { CostHistoryChart } from "./CostHistoryChart"; import { CreditsHistoryChart } from "./CreditsHistoryChart"; import { TokensHistoryChart } from "./TokensHistoryChart"; import { UsageBreakdownChart } from "./UsageBreakdownChart"; +import { QuotaWindowHistory } from "../../../../../components/QuotaWindowHistory"; type T = ReturnType["t"]; @@ -108,7 +109,17 @@ export function ChartsSection({ // Tokens mode needs at least one day with exact local token data. const hasTokens = data.tokensHistory.some((p) => p.tokens > 0); - if (!hasCost && !hasCredits && !hasUsage && !hasTokens) return null; + // Recent quota windows (moved from the tray card's "Usage details"). + const history = + (data.quotaWindowHistory?.windows.length ?? 0) > 0 ? ( + + ) : null; + + if (!hasCost && !hasCredits && !hasUsage && !hasTokens) { + return history ? ( +
    {history}
    + ) : null; + } const available: TabKey[] = []; if (hasCost) available.push("cost"); @@ -196,6 +207,7 @@ export function ChartsSection({ /> )}
    + {history} ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index c0d314e750..1cbe8ac5a2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -57,3 +57,122 @@ .codex-accounts .credential-card__badge { align-self: flex-start; } + +/* Settings > Providers "Usage details": planning detail moved out of the tray card. */ +.provider-usage-details { + display: flex; + flex-direction: column; + gap: 12px; +} + +.provider-usage-details__pace { + display: flex; + flex-direction: column; + gap: 6px; +} + +.provider-usage-details__pace-delta { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.provider-usage-details__pace-delta[data-pace="slow"] { + color: var(--pace-slow-fg); +} + +.provider-usage-details__pace-delta[data-pace="steady"] { + color: var(--pace-steady-fg); +} + +.provider-usage-details__pace-delta[data-pace="racing"] { + color: var(--pace-racing-fg); +} + +.provider-usage-details__pace-delta[data-pace="burning"] { + color: var(--pace-burning-fg); +} + +.provider-usage-details__pace-row { + display: grid; + grid-template-columns: 64px 1fr; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.provider-usage-details__pace-track { + height: 6px; + border-radius: 3px; + background: var(--usage-bar-track); + overflow: hidden; +} + +.provider-usage-details__pace-fill { + height: 100%; + border-radius: inherit; + background: var(--usage-bar-normal); +} + +.provider-usage-details__pace-fill--expected { + background: var(--pace-track-expected-fg); +} + +.provider-usage-details__pace-fill[data-pace="slow"] { + background: var(--pace-slow-fg); +} + +.provider-usage-details__pace-fill[data-pace="steady"] { + background: var(--pace-steady-fg); +} + +.provider-usage-details__pace-fill[data-pace="racing"] { + background: var(--pace-racing-fg); +} + +.provider-usage-details__pace-fill[data-pace="burning"] { + background: var(--pace-burning-fg); +} + +.provider-usage-details__forecast { + font-size: 12px; + color: var(--text-secondary); +} + +.provider-usage-details__budget { + display: flex; + flex-direction: column; + gap: 6px; +} + +.provider-usage-details__budget-title { + font-size: 12px; + font-weight: 600; +} + +.provider-usage-details__budget-values { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.provider-usage-details__budget-value { + padding: 2px 6px; + border-radius: 4px; + background: var(--surface-elevated); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.provider-usage-details__pricing { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; +} + +.provider-usage-details__pricing span, +.provider-usage-details__pricing small { + color: var(--text-secondary); +} diff --git a/apps/desktop-tauri/src/surfaces/tray-card.css b/apps/desktop-tauri/src/surfaces/tray-card.css new file mode 100644 index 0000000000..73cd4641c1 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/tray-card.css @@ -0,0 +1,571 @@ +/* + * Tray card anatomy, after upstream 0.70.0 `UsageMenuCardView` (spec 3.1-3.8, 5). + * Imported last so it wins over the older layered tray rules. Every rule is + * scoped to the tray panel; Settings and the float bar keep their own styles. + */ + +/* ── Card frame: 20 px sides, 6 px top and bottom, blocks stacked by margins ── */ +.menu-surface--tray .menu-card { + gap: 0; + padding: var(--mac-pad-header-y) var(--mac-pad-x); +} + +.menu-surface--tray .menu-card--with-details { + padding: var(--mac-section-pt) var(--mac-pad-x) var(--mac-section-pb); +} + +/* Divider under the header: 6 above, 16 below (`postHeaderDividerContentSpacing`). */ +.menu-surface--tray .menu-card__divider { + flex: none; + height: 1px; + background: var(--mac-divider); + margin: var(--mac-gap-header) 0 var(--mac-after-divider); +} + +.menu-surface--tray .menu-card__content, +.menu-surface--tray .menu-card__metrics { + gap: var(--mac-metric-gap); + padding-bottom: 0; +} + +/* Separator before the extra usage block. */ +.menu-surface--tray .menu-card__separator { + flex: none; + height: 1px; + background: var(--mac-separator); +} + +/* ── Header: name and email, then subtitle (or error) and plan ── */ +.menu-surface--tray .card-header { + display: flex; + flex-direction: column; + gap: var(--mac-header-line-gap); + min-width: 0; +} + +.menu-surface--tray .card-header__line { + display: flex; + align-items: baseline; + gap: var(--mac-header-col-gap); + min-width: 0; +} + +.menu-surface--tray .card-header__line--error { + align-items: flex-start; +} + +.menu-surface--tray .card-header__name { + flex: 0 1 auto; + min-width: 0; + font: var(--mac-text-headline); + color: var(--mac-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Middle truncation: the head shrinks with an ellipsis, the tail stays whole. */ +.menu-surface--tray .card-header__email { + display: flex; + flex: 0 1 auto; + min-width: 0; + margin-left: auto; + font: var(--mac-text-subheadline); + color: var(--mac-text-2); + white-space: nowrap; +} + +.menu-surface--tray .card-header__email-head { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-header__email-tail { + flex: none; +} + +.menu-surface--tray .card-header__subtitle { + flex: 0 1 auto; + min-width: 0; + font: var(--mac-text-footnote); + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Plain footnote text: no badge fill or border. */ +.menu-surface--tray .card-header__plan { + flex: 0 1 auto; + min-width: 0; + margin-left: auto; + font: var(--mac-text-footnote); + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-header__error { + flex: 1 1 auto; + min-width: 0; + font: var(--mac-text-footnote); + color: var(--mac-error); + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Upstream `CopyIconButton`: pressed fill 0.18, scale 0.94, ease-out 0.12 s. */ +.menu-surface--tray .menu-card__copy-btn { + border-radius: var(--mac-icon-btn-r); + color: var(--mac-text-2); + transition: + transform 120ms ease-out, + background 120ms ease-out; +} + +.menu-surface--tray .menu-card__copy-btn:active { + background: rgba(var(--surface-tint-rgb), 0.18); + transform: scale(0.94); +} + +/* ── Rate-window rows (`MenuCardMetricRow`) ── */ +.menu-surface--tray .menu-metric { + gap: var(--mac-metric-inner); +} + +.menu-surface--tray .menu-metric__title { + min-width: 0; + font: var(--mac-text-body-medium); + color: var(--mac-text); + text-align: left; + text-align-last: auto; +} + +.menu-surface--tray .menu-metric__head { + column-gap: 8px; +} + +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__meta, +.menu-surface--tray .menu-metric__status, +.menu-surface--tray .menu-metric__detail, +.menu-surface--tray .menu-metric__pct { + font: var(--mac-text-footnote); + color: var(--mac-text-2); +} + +.menu-surface--tray .menu-metric__pct { + color: var(--mac-text); +} + +.menu-surface--tray .menu-metric__exhausted { + font: var(--mac-text-footnote); + font-weight: 600; + color: var(--mac-error); +} + +/* ── Key-value sections (`ProviderDetailSectionsContent`) ── */ +.menu-surface--tray .card-kv { + display: flex; + flex-direction: column; + gap: var(--mac-kv-gap); + min-width: 0; +} + +.menu-surface--tray .card-kv__divider { + height: 1px; + background: var(--mac-divider); +} + +.menu-surface--tray .card-kv__section { + display: flex; + flex-direction: column; + gap: var(--mac-kv-section); + min-width: 0; +} + +.menu-surface--tray .card-kv__title { + font: var(--mac-text-section); + color: var(--mac-text-2); + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-kv__item { + display: flex; + flex-direction: column; + gap: var(--mac-kv-row); + min-width: 0; +} + +.menu-surface--tray .card-kv__row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + min-width: 0; + font: var(--mac-text-caption); +} + +.menu-surface--tray .card-kv__label { + flex: 0 1 auto; + min-width: 0; + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-kv__values { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--mac-kv-value); + flex: 0 1 auto; + min-width: 0; + text-align: right; +} + +.menu-surface--tray .card-kv__value { + max-width: 100%; + color: var(--mac-text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-kv__secondary { + max-width: 100%; + font: var(--mac-text-caption2); + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-kv__track, +.menu-surface--tray .card-extra__track { + height: var(--mac-bar-h); + border-radius: var(--mac-bar-r); + background: var(--mac-track); + overflow: hidden; +} + +.menu-surface--tray .card-kv__fill { + height: 100%; + border-radius: inherit; + background: var(--provider-accent); +} + +/* ── Inline usage dashboard (`InlineUsageDashboardContent`) ── */ +.menu-surface--tray .card-dash { + display: flex; + flex-direction: column; + gap: var(--mac-dash-gap); + min-width: 0; +} + +.menu-surface--tray .card-dash__kpis { + display: grid; + grid-template-columns: minmax(118px, 1fr) minmax(100px, 1fr); + gap: var(--mac-kpi-gap); +} + +.menu-surface--tray .card-dash__kpi { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.menu-surface--tray .card-dash__kpi-title { + font: var(--mac-text-caption2); + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-dash__kpi-value { + font: var(--mac-text-subheadline); + font-weight: 600; + color: var(--mac-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-dash__kpi-value--emphasis { + font: var(--mac-text-headline); +} + +/* 58 px frame: the scale line, a 2 px gap, then the bars. */ +.menu-surface--tray .card-dash__chart { + display: flex; + flex-direction: column; + gap: 2px; + height: var(--mac-chart-h); + min-width: 0; +} + +.menu-surface--tray .card-dash__scale { + flex: none; + font: var(--mac-text-caption2); + color: var(--mac-text-2); + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-dash__scale--hover { + text-align: left; +} + +.menu-surface--tray .card-dash__bars { + position: relative; + display: flex; + flex: 1 1 auto; + align-items: flex-end; + gap: min(2px, calc(100% / var(--bar-count, 30) / 4)); + min-height: 0; +} + +/* 1 px baseline at secondary 0.22. */ +.menu-surface--tray .card-dash__bars::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: var(--mac-text-2); + opacity: 0.22; + pointer-events: none; +} + +.menu-surface--tray .card-dash__bar { + flex: 1 1 0; + min-width: 0; + border-radius: var(--mac-chart-bar-r); + background: var(--provider-accent); +} + +.menu-surface--tray .card-dash__bar--selected { + box-shadow: inset 0 0 0 1px var(--mac-text); +} + +.menu-surface--tray .card-dash__windows { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.menu-surface--tray .card-dash__caption { + font: var(--mac-text-caption2); + color: var(--mac-text-2); +} + +.menu-surface--tray .card-dash__window { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.menu-surface--tray .card-dash__window-line { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.menu-surface--tray .card-dash__window-title { + min-width: 0; + font: var(--mac-text-caption); + color: var(--mac-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-dash__window-value { + flex: none; + font: var(--mac-text-caption); + font-weight: 600; + color: var(--mac-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.menu-surface--tray .card-dash__window-range { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .card-dash__details { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.menu-surface--tray .card-dash__detail { + font: var(--mac-text-caption); + color: var(--mac-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Extra usage and cost (`MenuCardView+Costs`) ── */ +.menu-surface--tray .card-extra { + display: flex; + flex-direction: column; + gap: var(--mac-metric-inner); + min-width: 0; +} + +.menu-surface--tray .card-extra__title { + font: var(--mac-text-body-medium); + color: var(--mac-text); +} + +.menu-surface--tray .card-extra__fill { + height: 100%; + border-radius: inherit; + background: var(--provider-accent); +} + +.menu-surface--tray .card-extra__line { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.menu-surface--tray .card-extra__spend { + min-width: 0; + font: var(--mac-text-caption); + color: var(--mac-text); + overflow-wrap: anywhere; +} + +.menu-surface--tray .card-extra__inline-value { + min-width: 0; + font: var(--mac-text-caption); + color: var(--mac-text); + text-align: right; + overflow-wrap: anywhere; +} + +.menu-surface--tray .card-extra__caption { + font: var(--mac-text-caption); + color: var(--mac-text-2); + overflow-wrap: anywhere; +} + +/* ── Provider switcher (spec 3.1): radius 6, accent selection, instant hover ── */ +.menu-surface--tray .provider-grid__item { + position: relative; + min-height: 30px; + border-radius: 6px; + transition: none; +} + +.menu-surface--tray .provider-grid__item:hover { + background: rgba(0, 0, 0, 0.095); +} + +.menu-surface--tray .provider-grid__item--active { + background: var(--mac-selection-bg); + box-shadow: none; + color: var(--mac-selection-text); +} + +.menu-surface--tray .provider-grid__item--active:hover { + background: var(--mac-selection-bg); +} + +.menu-surface--tray .provider-grid__item--active .provider-icon { + filter: brightness(0) invert(1); + opacity: 1; +} + +.menu-surface--tray .provider-grid__item--active .provider-grid__icon-overview { + color: var(--mac-selection-text); +} + +/* Quota indicator: 2 px line, 2 px off the bottom; icon-only segments are + 24 px wide, so the side inset is 3 px instead of the labelled 8. */ +.menu-surface--tray .provider-grid__weekly-track { + position: absolute; + left: 3px; + right: 3px; + bottom: 2px; + width: auto; + height: 2px; + margin: 0; + border-radius: 1px; + background: var(--mac-track); +} + +.menu-surface--tray .provider-grid--compact .provider-grid__weekly-track { + width: auto; + height: 2px; + margin: 0; +} + +.menu-surface--tray .provider-grid__weekly-track::after { + border-radius: 1px; +} + +/* ── Overview spend summary (spec 3.2) ── */ +.menu-surface--tray .overview-spend { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 20px; + min-width: 0; +} + +.menu-surface--tray .overview-spend__headline { + font: var(--mac-text-headline); + color: var(--mac-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-surface--tray .overview-spend__range { + color: var(--mac-text-2); +} + +.menu-surface--tray .overview-spend__total { + font-family: var(--mac-font); + font-size: 17px; + line-height: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--mac-text); +} + +.menu-surface--tray .overview-spend__caption { + font: var(--mac-text-caption); + color: var(--mac-text-2); +} diff --git a/apps/desktop-tauri/src/surfaces/trayCardStyles.test.ts b/apps/desktop-tauri/src/surfaces/trayCardStyles.test.ts new file mode 100644 index 0000000000..3374204cca --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/trayCardStyles.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { loadStyles, loadStylesheet, ruleBlock } from "../test/styles"; + +// jsdom never applies CSS, so these pin the card anatomy rules (spec 3.1-3.8, 5) +// in the stylesheet text. +const tray = loadStylesheet("surfaces/tray-card.css"); +const settings = loadStylesheet("surfaces/settings/settings-layout.css"); +const base = loadStyles(); +const main = loadStylesheet("main.tsx"); + +describe("tray card stylesheet", () => { + it("loads after every other stylesheet so it wins the tray cascade", () => { + const imports = [...main.matchAll(/^import "(.+\.css)";$/gm)].map((match) => match[1]); + expect(imports[imports.length - 1]).toBe("./surfaces/tray-card.css"); + }); + + it("scopes every rule to the tray surface", () => { + const withoutComments = tray.replace(/\/\*[\s\S]*?\*\//g, ""); + const selectors = [...withoutComments.matchAll(/(^|})\s*([^{}@]+)\{/g)].map((m) => + m[2].trim(), + ); + expect(selectors.length).toBeGreaterThan(20); + for (const group of selectors) { + for (const selector of group.split(",")) { + expect(selector.trim(), group).toMatch(/^\.menu-surface--tray\b/); + } + } + }); + + it("styles the header as name and email, then subtitle and plain plan text", () => { + expect(ruleBlock(tray, ".menu-surface--tray .card-header__name")).toContain( + "font: var(--mac-text-headline)", + ); + const email = ruleBlock(tray, ".menu-surface--tray .card-header__email"); + expect(email).toContain("var(--mac-text-subheadline)"); + expect(email).toContain("color: var(--mac-text-2)"); + const plan = ruleBlock(tray, ".menu-surface--tray .card-header__plan"); + expect(plan).toContain("var(--mac-text-footnote)"); + expect(plan).not.toMatch(/border|background/); + const error = ruleBlock(tray, ".menu-surface--tray .card-header__error"); + expect(error).toContain("color: var(--mac-error)"); + expect(error).toContain("-webkit-line-clamp: 4"); + }); + + it("presses the copy button with a .18 fill and a .94 scale over 120ms", () => { + const active = ruleBlock(tray, ".menu-surface--tray .menu-card__copy-btn:active"); + expect(active).toContain("scale(0.94)"); + expect(active).toContain("0.18"); + expect(ruleBlock(tray, ".menu-surface--tray .menu-card__copy-btn")).toContain("120ms"); + }); + + it("puts 6px over and 16px under the header divider and uses the separator tone between blocks", () => { + const divider = ruleBlock(tray, ".menu-surface--tray .menu-card__divider"); + expect(divider).toContain("background: var(--mac-divider)"); + expect(divider).toContain("margin: var(--mac-gap-header) 0 var(--mac-after-divider)"); + expect(ruleBlock(tray, ".menu-surface--tray .menu-card__separator")).toContain( + "background: var(--mac-separator)", + ); + }); + + it("lays out key-value rows as caption label left and medium value right", () => { + expect(ruleBlock(tray, ".menu-surface--tray .card-kv__title")).toContain( + "text-transform: uppercase", + ); + expect(ruleBlock(tray, ".menu-surface--tray .card-kv__row")).toContain( + "justify-content: space-between", + ); + expect(ruleBlock(tray, ".menu-surface--tray .card-kv__value")).toContain("font-weight: 500"); + }); + + it("draws the inline dashboard as a two-column KPI grid over a 58px chart", () => { + expect(ruleBlock(tray, ".menu-surface--tray .card-dash__kpis")).toContain( + "grid-template-columns: minmax(118px, 1fr) minmax(100px, 1fr)", + ); + expect(ruleBlock(tray, ".menu-surface--tray .card-dash__chart")).toContain( + "height: var(--mac-chart-h)", + ); + const bar = ruleBlock(tray, ".menu-surface--tray .card-dash__bar"); + expect(bar).toContain("background: var(--provider-accent)"); + expect(bar).toContain("var(--mac-chart-bar-r)"); + }); + + it("never animates progress widths", () => { + expect(tray).not.toMatch(/transition:[^;]*width/); + expect(ruleBlock(tray, ".menu-surface--tray .card-extra__fill")).toContain( + "background: var(--provider-accent)", + ); + }); + + it("restyles the switcher: accent selection, instant black .095 hover, 2px quota line", () => { + const item = ruleBlock(tray, ".menu-surface--tray .provider-grid__item"); + expect(item).toContain("border-radius: 6px"); + expect(item).toContain("transition: none"); + expect(ruleBlock(tray, ".menu-surface--tray .provider-grid__item:hover")).toContain( + "rgba(0, 0, 0, 0.095)", + ); + const active = ruleBlock(tray, ".menu-surface--tray .provider-grid__item--active"); + expect(active).toContain("background: var(--mac-selection-bg)"); + expect(active).toContain("box-shadow: none"); + expect(ruleBlock(tray, ".menu-surface--tray .provider-grid__weekly-track")).toContain( + "height: 2px", + ); + }); + + it("restyles the Overview spend summary as headline, 17px total and captions", () => { + expect(ruleBlock(tray, ".menu-surface--tray .overview-spend")).toContain("padding: 10px 20px"); + const total = ruleBlock(tray, ".menu-surface--tray .overview-spend__total"); + expect(total).toContain("font-size: 17px"); + expect(total).toContain("font-weight: 700"); + expect(total).toContain("tabular-nums"); + }); +}); + +describe("per-surface tint token", () => { + it("is white on dark surfaces and black on light ones", () => { + expect(base).toMatch(/:root\s*\{[^}]*--surface-tint-rgb: 255, 255, 255;/); + expect(base).toMatch(/\[data-theme="light"\]\s*\{[^}]*--surface-tint-rgb: 0, 0, 0;/); + expect(base).toMatch(/\.menu-surface--tray\s*\{[^}]*--surface-tint-rgb: 0, 0, 0;/); + }); + + it("replaces the tray-only dark-tint overrides", () => { + for (const selector of [ + ".menu-surface--tray .menu-card__copy-btn:hover", + ".menu-surface--tray .update-banner__action:hover", + ".menu-surface--tray .credential-btn", + ".menu-surface--tray .provider-grid__item--more .provider-grid__icon-overview", + ]) { + expect(base).not.toContain(`${selector} {`); + } + expect(ruleBlock(base, ".menu-card__copy-btn:hover")).toContain("var(--surface-tint-rgb)"); + expect(ruleBlock(base, ".update-banner__action:hover")).toContain("var(--surface-tint-rgb)"); + expect(ruleBlock(base, ".credential-btn:hover:not(:disabled)")).toContain( + "var(--surface-tint-rgb)", + ); + }); +}); + +describe("Settings usage details", () => { + it("colors the pace bars from the pace tokens and keeps budget values off pills", () => { + expect( + ruleBlock(settings, '.provider-usage-details__pace-fill[data-pace="burning"]'), + ).toContain("var(--pace-burning-fg)"); + expect(ruleBlock(settings, ".provider-usage-details__pace-fill--expected")).toContain( + "var(--pace-track-expected-fg)", + ); + expect(ruleBlock(settings, ".provider-usage-details__budget-value")).toContain( + "border-radius: 4px", + ); + }); +}); diff --git a/apps/desktop-tauri/src/test/styles.ts b/apps/desktop-tauri/src/test/styles.ts index e76aa3e129..076996c575 100644 --- a/apps/desktop-tauri/src/test/styles.ts +++ b/apps/desktop-tauri/src/test/styles.ts @@ -18,6 +18,21 @@ export function loadStyles(): string { return cached; } +const sheets = new Map(); + +/** Contents of another stylesheet under src/, by path relative to src/. */ +export function loadStylesheet(path: string): string { + let text = sheets.get(path); + if (text === undefined) { + if (!import.meta.dirname) { + throw new Error("import.meta.dirname unavailable to vitest runner"); + } + text = readFileSync(`${import.meta.dirname}/../${path}`, "utf8"); + sheets.set(path, text); + } + return text; +} + /** * Declarations of the first rule whose selector is exactly `selector`. The * match is anchored to a line start so `.a` cannot match inside `.b .a`. From 2e0610cf429a6fa30b14c0af2fcf57ace5856baf Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:08:48 +0700 Subject: [PATCH 03/13] Keep the extra usage percent on one line --- apps/desktop-tauri/src/surfaces/tray-card.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop-tauri/src/surfaces/tray-card.css b/apps/desktop-tauri/src/surfaces/tray-card.css index 73cd4641c1..fbf2c0b7c4 100644 --- a/apps/desktop-tauri/src/surfaces/tray-card.css +++ b/apps/desktop-tauri/src/surfaces/tray-card.css @@ -480,6 +480,12 @@ overflow-wrap: anywhere; } +/* The percent stays whole on the right while a long spend line wraps. */ +.menu-surface--tray .card-extra__line > .card-extra__caption { + flex: none; + white-space: nowrap; +} + /* ── Provider switcher (spec 3.1): radius 6, accent selection, instant hover ── */ .menu-surface--tray .provider-grid__item { position: relative; From a9b26dcfe3344e4562d9c7ad11e5d3da17d39727 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:13:03 +0700 Subject: [PATCH 04/13] Test header email masking under Hide Personal Info --- .../src/components/MenuCard.test.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 6e85520ef3..8e03342cf0 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -104,6 +104,7 @@ function renderCard( costSummaryDisplayStyle?: "compact" | "detailed" | "hidden"; usageThresholds?: UsageThresholdSettings | null; weeklyProgressWorkDays?: number | null; + hideEmail?: boolean; } = {}, ) { return render( @@ -111,7 +112,7 @@ function renderCard( { + it("masks the header email under Hide Personal Info", async () => { + const snapshot = { ...provider(null, 20), accountEmail: "parity.user@example.com" }; + const { container, unmount } = renderCard(snapshot, { hideEmail: true }); + await waitFor(() => + expect(container.querySelector(".card-header__email")).toHaveTextContent( + "p••••••••••@example.com", + ), + ); + expect(container.textContent).not.toContain("parity.user@"); + unmount(); + + const shown = renderCard(snapshot); + await waitFor(() => + expect(shown.container.querySelector(".card-header__email")).toHaveTextContent( + "parity.user@example.com", + ), + ); + }); + beforeEach(() => { vi.clearAllMocks(); tauriMocks.claudeAccountsList.mockResolvedValue([]); From 1952741a58bdf30a826e9fc690d043d7fb076cf8 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:16:26 +0700 Subject: [PATCH 05/13] Skip a balance-only cost row that repeats a detail row --- .../src/components/MenuCard.test.tsx | 36 +++++++++++++++++++ .../src/components/card/describeCard.ts | 18 +++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 8e03342cf0..57ff9b0302 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1306,6 +1306,42 @@ describe("MenuCard", () => { expect(container.querySelector(".card-extra__track")).toBeNull(); }); + it("drops a balance-only cost row when the detail sections already show that balance", async () => { + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ ExtraUsage: "Extra usage", DetailCostBalance: "Balance" }), + ); + const snapshot = provider(null, 20); + snapshot.cost = { + used: 17.25, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Team credits", + resetsAt: null, + formattedUsed: "$17.25", + formattedLimit: null, + balance: 42.5, + formattedBalance: null, + }; + snapshot.displayDetails = [ + { + id: "team-balance", + sectionTitle: "Team credits", + title: "Available balance", + value: "$42.50", + secondaryValue: null, + progress: null, + }, + ]; + + const { container } = renderCard(snapshot); + + expect(await screen.findByText("$42.50")).toBeInTheDocument(); + expect(screen.getAllByText(/\$42\.50/)).toHaveLength(1); + expect(screen.queryByText("Extra usage")).not.toBeInTheDocument(); + expect(container.querySelector(".card-extra")).toBeNull(); + }); + it("localizes the relative updated-at time in Japanese without duplicated prefix", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle({ diff --git a/apps/desktop-tauri/src/components/card/describeCard.ts b/apps/desktop-tauri/src/components/card/describeCard.ts index abeaa5d230..df2fb80d26 100644 --- a/apps/desktop-tauri/src/components/card/describeCard.ts +++ b/apps/desktop-tauri/src/components/card/describeCard.ts @@ -3,6 +3,7 @@ import type { ProviderChartData, ProviderUsageSnapshot, } from "../../types/bridge"; +import { formatCostAmount } from "../../lib/cardCost"; import type { MetricEntry } from "./CardMetricRow"; export interface MenuCardPresence { @@ -15,6 +16,20 @@ export interface MenuCardPresence { hasDetails: boolean; } +/** + * A cost that carries only a balance (no cap, not a spend summary) adds + * nothing when a provider detail row already shows that balance. Upstream + * has no cost block for such providers, so the card keeps the detail row. + */ +function balanceRepeatsDetail(provider: ProviderUsageSnapshot): boolean { + const cost = provider.cost; + if (!cost || cost.balance == null || (cost.limit ?? 0) > 0 || cost.alwaysVisible === true) { + return false; + } + const balance = cost.formattedBalance || formatCostAmount(cost.balance, cost.currencyCode); + return (provider.displayDetails ?? []).some((detail) => detail.value.trim() === balance); +} + /** * Which card sections exist. A card with an error, or whose only content the * compact Overview drops, renders its header alone with no divider. @@ -34,7 +49,8 @@ export function describeCard( const hasCost = !hasError && !!provider.cost && - (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); + (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true) && + !balanceRepeatsDetail(provider); const localUsage = hasError ? null : chartData?.localUsage ?? null; const wayfinderUsage = !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; From 3550c9ec9349cc88251eb9cc7e74425d46e4ab56 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:17:57 +0700 Subject: [PATCH 06/13] Offer Switch Account for a live Claude identity without an email --- .../src/lib/accountMenuRow.test.ts | 17 +++++++++++++ apps/desktop-tauri/src/lib/accountMenuRow.ts | 24 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.test.ts b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts index 6d3bfcf6dc..1c60aacd00 100644 --- a/apps/desktop-tauri/src/lib/accountMenuRow.test.ts +++ b/apps/desktop-tauri/src/lib/accountMenuRow.test.ts @@ -61,6 +61,23 @@ describe("accountMenuAction", () => { ).toBe("TrayMenuSwitchAccount"); }); + it("treats a live Claude identity with quota windows as signed in, like upstream", () => { + const oauth = snapshot({ providerId: "claude", sourceLabel: "oauth", planName: "Claude (OAuth)" }); + expect(accountMenuAction(oauth)?.labelKey).toBe("TrayMenuSwitchAccount"); + // No identity, no quota window, or a failed refresh does not prove an account. + expect(accountMenuAction({ ...oauth, planName: " " })?.labelKey).toBe("AddAccountEllipsis"); + const informational = { ...window, isInformational: true }; + expect( + accountMenuAction({ ...oauth, primary: informational, selectedMetric: informational }) + ?.labelKey, + ).toBe("AddAccountEllipsis"); + expect(accountMenuAction({ ...oauth, error: "expired" })?.labelKey).toBe("AddAccountEllipsis"); + // The identity rule is Claude's; other providers still need an email. + expect( + accountMenuAction(snapshot({ providerId: "codex", planName: "ChatGPT Pro" }))?.labelKey, + ).toBe("AddAccountEllipsis"); + }); + it("ignores a blank email", () => { expect(accountMenuAction(snapshot({ accountEmail: " " }))?.labelKey).toBe("AddAccountEllipsis"); }); diff --git a/apps/desktop-tauri/src/lib/accountMenuRow.ts b/apps/desktop-tauri/src/lib/accountMenuRow.ts index 899ade3cfa..63e325e7d4 100644 --- a/apps/desktop-tauri/src/lib/accountMenuRow.ts +++ b/apps/desktop-tauri/src/lib/accountMenuRow.ts @@ -1,5 +1,5 @@ import type { LocaleKey } from "../i18n/keys"; -import type { ProviderUsageSnapshot } from "../types/bridge"; +import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../types/bridge"; import { providersTabRequest } from "../surfaces/settings/settingsTabs"; import { hasSuccessfulClaudeCliQuota } from "./claudeAccountActions"; @@ -22,9 +22,29 @@ export function accountMenuAction( ): AccountMenuAction | null { if (!provider || !ACCOUNT_MANAGER_PROVIDERS.has(provider.providerId)) return null; const hasAccount = - Boolean(provider.accountEmail?.trim()) || hasSuccessfulClaudeCliQuota(provider); + Boolean(provider.accountEmail?.trim()) || + hasLiveClaudeIdentity(provider) || + hasSuccessfulClaudeCliQuota(provider); return { labelKey: hasAccount ? "TrayMenuSwitchAccount" : "AddAccountEllipsis", settingsTab: providersTabRequest(provider.providerId), }; } + +function isQuotaWindow(window: RateWindowSnapshot | null): boolean { + return window !== null && !window.isInformational && Number.isFinite(window.usedPercent); +} + +/** + * Upstream `MenuDescriptor.hasAccount`: a Claude snapshot with an identity and + * rate-limit windows is signed in even without an email. The bridge carries + * the identity's login method as `planName`. + */ +function hasLiveClaudeIdentity(provider: ProviderUsageSnapshot): boolean { + return ( + provider.providerId === "claude" && + provider.error === null && + Boolean(provider.planName?.trim()) && + (isQuotaWindow(provider.primary) || isQuotaWindow(provider.secondary)) + ); +} From 6cc9a629960887cd78b88eb752ef465f2b5423dd Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:55:51 +0700 Subject: [PATCH 07/13] Build the Codex quota history after the local cost scan --- .../src-tauri/src/commands/chart.rs | 209 +++++++++--------- 1 file changed, 110 insertions(+), 99 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index c2c37fc7b7..75dce9a141 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -190,116 +190,127 @@ fn build_provider_chart_data_with_cancel( weekly_window: Option, ) -> ProviderChartData { let live_window = weekly_window.as_ref().map(rate_window_from_snapshot); - let provider_snapshot = codexbar::providers::chart::build_chart_snapshot( - &provider_id, - account_email.as_deref(), - live_window.as_ref(), - cancel.as_deref(), - ); - let (cost_history, tokens_history, tokens_incomplete, local_usage) = if let Some(snapshot) = - provider_snapshot - .as_ref() - .filter(|_| provider_id == "claude") - { - let cost_history = snapshot - .daily_cost - .iter() - .cloned() - .map(|(date, value)| { - let incomplete_request_count = snapshot - .daily_incomplete - .iter() - .find(|(day, _)| *day == date) - .map(|(_, count)| *count); - DailyCostPoint { - date, - value, - incomplete_request_count, - } - }) - .collect(); - let tokens_history = snapshot - .daily_tokens - .iter() - .cloned() - .map(|(date, tokens)| DailyTokenPoint { date, tokens }) - .collect(); - let period = current_reporting_period(); - let local_usage = snapshot.local_summary.as_ref().and_then(|summary| { - let period_summary = - period_summary_for(&provider_id, period, summary, cancel.as_deref()); - local_usage_summary_from_cost_summary( - &provider_id, - summary, - period, - period_summary.as_ref(), - snapshot.today.as_ref(), - ) - }); - store_local_usage_summary(&provider_id, local_usage.clone()); - ( - cost_history, - tokens_history, - snapshot.tokens_incomplete, - local_usage, + let build_snapshot = || { + codexbar::providers::chart::build_chart_snapshot( + &provider_id, + account_email.as_deref(), + live_window.as_ref(), + cancel.as_deref(), ) - } else if provider_id == "muse" { - if cancel - .as_ref() - .is_some_and(|flag| flag.load(Ordering::Relaxed)) - { - (Vec::new(), Vec::new(), true, None) - } else { - let report = muse_local_usage::scan(30, cancel.as_deref()); - let tokens_history = report - .daily + }; + // Claude's cost, token and local-usage series come from its snapshot, so it + // is built first. Every other snapshot is built after the local scans below. + let claude_snapshot = if provider_id == "claude" { + build_snapshot() + } else { + None + }; + let (cost_history, tokens_history, tokens_incomplete, local_usage) = + if let Some(snapshot) = claude_snapshot.as_ref() { + let cost_history = snapshot + .daily_cost .iter() - .map(|day| DailyTokenPoint { - date: day.day.clone(), - tokens: day.total_tokens, + .cloned() + .map(|(date, value)| { + let incomplete_request_count = snapshot + .daily_incomplete + .iter() + .find(|(day, _)| *day == date) + .map(|(_, count)| *count); + DailyCostPoint { + date, + value, + incomplete_request_count, + } }) .collect(); + let tokens_history = snapshot + .daily_tokens + .iter() + .cloned() + .map(|(date, tokens)| DailyTokenPoint { date, tokens }) + .collect(); let period = current_reporting_period(); - let period_report = muse_period_report(period, &report, cancel.as_deref()); - let local_usage = muse_local_usage_summary( - &report, - &period_report, - period, - locale::current_language(), - ); + let local_usage = snapshot.local_summary.as_ref().and_then(|summary| { + let period_summary = + period_summary_for(&provider_id, period, summary, cancel.as_deref()); + local_usage_summary_from_cost_summary( + &provider_id, + summary, + period, + period_summary.as_ref(), + snapshot.today.as_ref(), + ) + }); + store_local_usage_summary(&provider_id, local_usage.clone()); ( - Vec::new(), + cost_history, tokens_history, - !report.is_complete(), + snapshot.tokens_incomplete, local_usage, ) - } - } else { - let raw_cost = get_daily_cost_history(&provider_id, 30); - let cost_history: Vec = raw_cost - .into_iter() - .map(|(date, value)| DailyCostPoint { - date, - value, - incomplete_request_count: None, - }) - .collect(); - - let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30); - let tokens_history: Vec = raw_tokens - .into_iter() - .map(|(date, tokens)| DailyTokenPoint { date, tokens }) - .collect(); - let local_usage = if cancel - .as_deref() - .is_some_and(|flag| flag.load(Ordering::Relaxed)) - { - None + } else if provider_id == "muse" { + if cancel + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + (Vec::new(), Vec::new(), true, None) + } else { + let report = muse_local_usage::scan(30, cancel.as_deref()); + let tokens_history = report + .daily + .iter() + .map(|day| DailyTokenPoint { + date: day.day.clone(), + tokens: day.total_tokens, + }) + .collect(); + let period = current_reporting_period(); + let period_report = muse_period_report(period, &report, cancel.as_deref()); + let local_usage = muse_local_usage_summary( + &report, + &period_report, + period, + locale::current_language(), + ); + ( + Vec::new(), + tokens_history, + !report.is_complete(), + local_usage, + ) + } } else { - load_local_usage_summary_cached(&provider_id, cancel.as_deref()) + let raw_cost = get_daily_cost_history(&provider_id, 30); + let cost_history: Vec = raw_cost + .into_iter() + .map(|(date, value)| DailyCostPoint { + date, + value, + incomplete_request_count: None, + }) + .collect(); + + let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30); + let tokens_history: Vec = raw_tokens + .into_iter() + .map(|(date, tokens)| DailyTokenPoint { date, tokens }) + .collect(); + let local_usage = if cancel + .as_deref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + None + } else { + load_local_usage_summary_cached(&provider_id, cancel.as_deref()) + }; + (cost_history, tokens_history, tokens_incomplete, local_usage) }; - (cost_history, tokens_history, tokens_incomplete, local_usage) - }; + + // Codex quota-window history reads the Codex cost cache that + // get_daily_cost_history writes above. Building it before that scan left a + // cold start with an empty cache and the current window without totals. + let provider_snapshot = claude_snapshot.or_else(build_snapshot); let (credits_history, usage_breakdown) = load_openai_dashboard_chart_data(&provider_id, account_email.as_deref()); From cf77730e9c2842b11ff1526c2fecb96cd29d569f Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:55:51 +0700 Subject: [PATCH 08/13] Accept string credit balances from the Codex usage API --- rust/src/providers/codex/api.rs | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 5baf943126..291724881d 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -973,7 +973,7 @@ impl CodexApi { let balance = credits .get("balance") - .and_then(|v| v.as_f64()) + .and_then(credit_balance_value) .unwrap_or(0.0); Some(CostSnapshot::new(balance, "USD", "Credits")) @@ -1119,6 +1119,17 @@ fn normalize_named_windows( /// Returns (session, weekly, monthly, code_review). F5 (upstream 0.48.0): /// monthly (30-day) windows are routed to their own lane so surfaces can /// display a monthly reset instead of swallowing it into the weekly label. +/// The usage API sends `credits.balance` as a number or as a numeric string +/// ("125.50"). Upstream `CreditDetails` accepts both; anything else is unknown. +fn credit_balance_value(value: &serde_json::Value) -> Option { + let balance = match value { + serde_json::Value::Number(number) => number.as_f64(), + serde_json::Value::String(text) => text.trim().parse::().ok(), + _ => None, + }?; + balance.is_finite().then_some(balance) +} + fn normalize_array_windows( windows: Vec, ) -> ( @@ -2347,6 +2358,37 @@ mod tests { assert!(usage.extra_rate_windows.is_empty()); } + fn credits_cost_from_json(balance: serde_json::Value) -> Option { + CodexApi::new() + .build_result_from_json_for_test(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 10, "limit_window_seconds": 18000 } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": balance } + })) + .expect("codex usage") + .1 + } + + #[test] + fn credits_balance_accepts_numeric_string() { + let cost = credits_cost_from_json(json!("125.50")).expect("credits cost"); + assert_eq!(cost.used, 125.5); + assert_eq!(cost.currency_code, "USD"); + } + + #[test] + fn credits_balance_accepts_number() { + let cost = credits_cost_from_json(json!(7.5)).expect("credits cost"); + assert_eq!(cost.used, 7.5); + } + + #[test] + fn credits_balance_falls_back_to_zero_for_non_numeric_string() { + let cost = credits_cost_from_json(json!("abc")).expect("credits cost"); + assert_eq!(cost.used, 0.0); + } + #[test] fn maps_top_level_individual_credit_limit_to_cost_snapshot() { let api = CodexApi::new(); From 239ddf07abd869e5f7cd2eceab832aa58698c51a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:55:51 +0700 Subject: [PATCH 09/13] Mask Settings Claude and Grok accounts under Hide Personal Info --- .../settings/providers/ProviderDetailPane.tsx | 21 +++++++++- .../ClaudeAccountsSection.test.tsx | 40 +++++++++++++++++++ .../credentials/ClaudeAccountsSection.tsx | 38 +++++++++++++++--- .../credentials/GrokAccountsSection.test.tsx | 8 ++++ .../credentials/GrokAccountsSection.tsx | 19 +++++++-- .../surfaces/settings/tabs/ProvidersTab.tsx | 1 + 6 files changed, 116 insertions(+), 11 deletions(-) diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 127792daf1..e861806d5a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -66,6 +66,8 @@ interface Props { liveSnapshot?: ProviderUsageSnapshot | null; cookieDomain?: string | null; resetTimeRelative: boolean; + /** Show "N% used" next to remaining on per-account usage rows. */ + showAsUsed?: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; copilotSeatCreditEntitlement: SettingsSnapshot["copilotSeatCreditEntitlement"]; /** Per-provider accent color overrides (CLI name → hex color). */ @@ -88,6 +90,7 @@ export function ProviderDetailPane({ liveSnapshot = null, cookieDomain = null, resetTimeRelative, + showAsUsed = false, providerMetrics, copilotSeatCreditEntitlement, providerAccentColors, @@ -336,8 +339,22 @@ export function ProviderDetailPane({ {detail.id === "codex" && ( )} - {detail.id === "claude" && } - {detail.id === "grok" && } + {detail.id === "claude" && ( + + )} + {detail.id === "grok" && ( + + )} {detail.lastError && ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx index e930485f27..a8b161613d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ const events = vi.hoisted(() => ({ listen: vi.fn<(event: string, listener: () => void) => Promise<() => void>>() })); vi.mock("../../../../../lib/tauri", () => mocks); vi.mock("@tauri-apps/api/event", () => events); +vi.mock("../../../../../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); import { ClaudeAccountsSection } from "./ClaudeAccountsSection"; const t = (key: string) => key; @@ -96,4 +97,43 @@ describe("ClaudeAccountsSection", () => { await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Could not read")); expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false); }); + + it("masks account emails, organizations and aria-labels under Hide Personal Info", async () => { + mocks.claudeAccountsList.mockResolvedValue([ + { ...current, organization: "Acme Org" }, + { ...other, organization: "Acme Org", needsAuthentication: true }, + ]); + const { container } = render(); + await screen.findByText("Account 1"); + expect(screen.getByText("Account 2")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "CodexAccountsReauthenticateButton: Account 2" }), + ).toBeInTheDocument(); + expect(container.innerHTML).not.toContain("one@example.com"); + expect(container.innerHTML).not.toContain("two@example.com"); + expect(container.innerHTML).not.toContain("Acme Org"); + }); + + it("shows emails and organizations when Hide Personal Info is off", async () => { + mocks.claudeAccountsList.mockResolvedValue([{ ...current, organization: "Acme Org" }]); + render(); + await screen.findByText("one@example.com"); + expect(screen.getByText("Acme Org · max")).toBeInTheDocument(); + }); + + it("passes the saved show-as-used preference to per-account usage", async () => { + mocks.claudeAccountsList.mockResolvedValue([{ + ...current, + usage: { + fiveHour: { usedPercent: 30, resetsAt: null }, + sevenDay: null, + updatedAt: "2026-10-11T00:00:00Z", + }, + }]); + const { rerender } = render(); + await screen.findByText("70% PanelLeftSuffix"); + expect(screen.getByText("30% PanelUsedSuffix")).toBeInTheDocument(); + rerender(); + expect(screen.queryByText("30% PanelUsedSuffix")).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index 333c1c0083..06c0711212 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -15,6 +15,10 @@ import { } from "../../../../../lib/tauri"; import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; import ClaudeAccountUsage from "../../../../../components/ClaudeAccountUsage"; +import { + buildClaudeAccountOrdinals, + buildPrivateClaudeAccountLabel, +} from "../../../../../components/claudeAccountDisplay"; import { localClaudeReconciliationOutcome, useClaudeReconciliation, @@ -23,9 +27,15 @@ import { export function ClaudeAccountsSection({ t, language = "english", + hidePersonalInfo = false, + showAsUsed = false, + resetTimeRelative = true, }: { t: (key: LocaleKey) => string; language?: Language; + hidePersonalInfo?: boolean; + showAsUsed?: boolean; + resetTimeRelative?: boolean; }) { const [accounts, setAccounts] = useState([]); const [busy, setBusy] = useState(false); @@ -89,6 +99,7 @@ export function ClaudeAccountsSection({ } } }; + const accountOrdinals = buildClaudeAccountOrdinals(accounts); return ( <>
    @@ -100,18 +111,32 @@ export function ClaudeAccountsSection({ {loggingIn &&

    {t("ClaudeAccountsSigningIn")}

    } {accounts.length === 0 &&

    {t("ClaudeAccountsEmpty")}

    }
      - {accounts.map(account => ( + {accounts.map(account => { + const privateLabel = buildPrivateClaudeAccountLabel( + account, + accountOrdinals[account.id], + hidePersonalInfo, + t("Account"), + ); + return (
    • - {account.email} + {privateLabel.label} {[ - account.organization?.includes(account.email) ? null : account.organization, + hidePersonalInfo || account.organization?.includes(account.email) + ? null + : account.organization, account.plan, ].filter(Boolean).join(" · ")} - + {account.isActive && ( {t("TokenAccountActive")} @@ -122,7 +147,7 @@ export function ClaudeAccountsSection({ {account.needsAuthentication &&
    • - ))} + ); + })}