diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 3c03116d93..f1b0409d88 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -35,16 +35,24 @@ vi.mock("@tauri-apps/api/core", () => coreMocks); import FloatBar from "./FloatBar"; import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; -import type { BootstrapState, ProviderUsageSnapshot, SettingsSnapshot } from "../types/bridge"; +import type { + BootstrapState, + ProviderUsageSnapshot, + RateWindowSnapshot, + SettingsSnapshot, +} from "../types/bridge"; + +type RateWindowOptions = { + exhausted?: boolean; + informational?: boolean; + resetsAt?: string | null; + resetDescription?: string | null; +}; function rateWindow( used: number, - opts: { - exhausted?: boolean; - resetsAt?: string | null; - resetDescription?: string | null; - } = {}, -) { + opts: RateWindowOptions = {}, +): RateWindowSnapshot { return { usedPercent: used, remainingPercent: 100 - used, @@ -52,6 +60,7 @@ function rateWindow( resetsAt: opts.resetsAt ?? null, resetDescription: opts.resetDescription ?? null, isExhausted: opts.exhausted ?? false, + isInformational: opts.informational, reservePercent: null, reserveDescription: null, }; @@ -66,13 +75,23 @@ function snapshot( error?: string | null; resetsAt?: string | null; resetDescription?: string | null; + informational?: boolean; + secondary?: { + used: number; + exhausted?: boolean; + informational?: boolean; + resetsAt?: string | null; + resetDescription?: string | null; + }; } = {}, ): ProviderUsageSnapshot { return { providerId: id, displayName: display, primary: rateWindow(used, opts), - secondary: null, + secondary: opts.secondary + ? rateWindow(opts.secondary.used, opts.secondary) + : null, modelSpecific: null, tertiary: null, extraRateWindows: [], @@ -214,6 +233,94 @@ describe("FloatBar", () => { expect(titles[1]).toMatch(/Claude: 20% used/); }); + it("keeps a normal primary window when a secondary window is available", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 20, { secondary: { used: 90 } }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + const { container } = renderFloatBar(bootstrap()); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toContain( + "Claude: 20% used", + ); + }); + }); + + it("uses a real secondary window when the primary window is informational", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 10, { + informational: true, + secondary: { + used: 80, + resetsAt: null, + resetDescription: "Resets in 2 hours", + }, + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowResetInline: true }), + ); + + const { container } = renderFloatBar(bootstrap({ floatBarShowResetInline: true })); + await waitFor(() => { + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.getAttribute("title")).toContain("Claude: 80% used\nResets in 2 hours"); + expect(pill?.classList.contains("floatbar__pill--warn")).toBe(true); + expect(container.querySelector(".floatbar__reset")?.textContent).toContain("2 hours"); + }); + }); + + it("keeps an informational primary window when no secondary window is available", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 10, { informational: true }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + const { container } = renderFloatBar(bootstrap()); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toContain( + "Claude: 10% used", + ); + }); + }); + + it("keeps an informational primary window when the secondary window is informational", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 10, { + informational: true, + secondary: { used: 90, informational: true }, + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + const { container } = renderFloatBar(bootstrap()); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toContain( + "Claude: 10% used", + ); + }); + }); + + it("sorts providers by their effective rate window", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 90, { + informational: true, + secondary: { used: 20 }, + }), + snapshot("codex", "Codex", 50), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + const { container } = renderFloatBar(bootstrap()); + await waitFor(() => { + const titles = Array.from(container.querySelectorAll(".floatbar__pill")).map( + (pill) => pill.getAttribute("title"), + ); + expect(titles).toEqual(["Codex: 50% used", "Claude: 20% used"]); + }); + }); + it("loads local cost summaries without using the foreground chart endpoint", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index 8536fa3c39..378d495966 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -17,6 +17,7 @@ import { getSettingsSnapshot, refreshProvidersIfStale, } from "../lib/tauri"; +import { selectSingleMetricUsageWindow } from "../lib/usageWindows"; import { ProviderIcon } from "../components/providers/ProviderIcon"; import { getProviderIcon } from "../components/providers/providerIcons"; import type { @@ -178,11 +179,12 @@ function ProviderPill({ usedSuffix: string; remainingSuffix: string; }) { - const remaining = Math.max(0, Math.min(100, provider.primary.remainingPercent)); - const used = Math.max(0, Math.min(100, provider.primary.usedPercent)); + const rateWindow = selectSingleMetricUsageWindow(provider); + const remaining = Math.max(0, Math.min(100, rateWindow.remainingPercent)); + const used = Math.max(0, Math.min(100, rateWindow.usedPercent)); const displayPercent = showAsUsed ? used : remaining; const displaySuffix = showAsUsed ? usedSuffix : remainingSuffix; - const exhausted = provider.primary.isExhausted || provider.error; + const exhausted = rateWindow.isExhausted || provider.error; let tone: "ok" | "warn" | "crit" = "ok"; if (exhausted || remaining <= critRemaining) tone = "crit"; else if (remaining <= highRemaining) tone = "warn"; @@ -190,8 +192,8 @@ function ProviderPill({ const brand = getProviderIcon(provider.providerId).brandColor; const label = provider.error ? "—" : `${Math.round(displayPercent)}%`; const resetText = useFormattedResetTime( - provider.primary.resetsAt, - provider.primary.resetDescription, + rateWindow.resetsAt, + rateWindow.resetDescription, resetRelative, ); const resetSuffix = resetText ? `\n${resetText}` : ""; @@ -308,7 +310,11 @@ export default function FloatBar({ state }: { state: BootstrapState }) { const wanted = new Set(filterIds); list = list.filter((p) => wanted.has(p.providerId)); } - return [...list].sort((a, b) => b.primary.usedPercent - a.primary.usedPercent); + return [...list].sort( + (a, b) => + selectSingleMetricUsageWindow(b).usedPercent - + selectSingleMetricUsageWindow(a).usedPercent, + ); }, [providers, settings.enabledProviders, filterIds]); const visibleCostTargets = useMemo( diff --git a/apps/desktop-tauri/src/lib/usageWindows.test.ts b/apps/desktop-tauri/src/lib/usageWindows.test.ts new file mode 100644 index 0000000000..2876b33810 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageWindows.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { RateWindowSnapshot } from "../types/bridge"; +import { selectSingleMetricUsageWindow } from "./usageWindows"; + +function rateWindow( + overrides: Partial = {}, +): RateWindowSnapshot { + return { + usedPercent: 0, + remainingPercent: 100, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + ...overrides, + }; +} + +describe("selectSingleMetricUsageWindow", () => { + it("returns a normal primary when a secondary exists", () => { + const primary = rateWindow(); + const secondary = rateWindow({ isInformational: true }); + + expect(selectSingleMetricUsageWindow({ primary, secondary })).toBe(primary); + }); + + it("returns a real secondary when the primary is informational", () => { + const primary = rateWindow({ isInformational: true }); + const secondary = rateWindow(); + + expect(selectSingleMetricUsageWindow({ primary, secondary })).toBe(secondary); + }); + + it("returns an informational primary when the secondary is null", () => { + const primary = rateWindow({ isInformational: true }); + + expect(selectSingleMetricUsageWindow({ primary, secondary: null })).toBe(primary); + }); + + it("returns an informational primary when the secondary is informational", () => { + const primary = rateWindow({ isInformational: true }); + const secondary = rateWindow({ isInformational: true }); + + expect(selectSingleMetricUsageWindow({ primary, secondary })).toBe(primary); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageWindows.ts b/apps/desktop-tauri/src/lib/usageWindows.ts new file mode 100644 index 0000000000..9a55daf098 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageWindows.ts @@ -0,0 +1,14 @@ +import type { + ProviderUsageSnapshot, + RateWindowSnapshot, +} from "../types/bridge"; + +/** Selects the automatic window for surfaces rendering exactly one primary/secondary usage metric. */ +export function selectSingleMetricUsageWindow( + provider: Pick, +): RateWindowSnapshot { + const { primary, secondary } = provider; + return primary.isInformational && secondary && !secondary.isInformational + ? secondary + : primary; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx new file mode 100644 index 0000000000..3a6bc02d7c --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { + ProviderCatalogEntry, + ProviderUsageSnapshot, + RateWindowSnapshot, + SettingsSnapshot, +} from "../../../types/bridge"; + +const hookMocks = vi.hoisted(() => ({ + useProviders: vi.fn(), +})); + +vi.mock("../../../hooks/useProviders", () => hookMocks); +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); +vi.mock("../../../lib/tauri", () => ({ + reorderProviders: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../providers/ProviderDetailPane", () => ({ + ProviderDetailPane: () => null, +})); + +import ProvidersTab from "./ProvidersTab"; + +function rateWindow( + usedPercent: number, + isInformational?: boolean, +): RateWindowSnapshot { + return { + usedPercent, + remainingPercent: 100 - usedPercent, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + isInformational, + reservePercent: null, + reserveDescription: null, + }; +} + +const provider: ProviderCatalogEntry = { + id: "codex", + displayName: "Codex", + cookieDomain: null, +}; + +const settings = { + enabledProviders: [provider.id], + resetTimeRelative: true, + providerMetrics: {}, +} as SettingsSnapshot; + +describe("ProvidersTab", () => { + it("shows the real secondary percentage when the primary is informational", () => { + const snapshot: ProviderUsageSnapshot = { + providerId: provider.id, + displayName: provider.displayName, + primary: rateWindow(0, true), + secondary: rateWindow(42), + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "auto", + updatedAt: new Date().toISOString(), + error: null, + pace: null, + accountOrganization: null, + trayStatusLabel: null, + }; + hookMocks.useProviders.mockReturnValue({ providers: [snapshot] }); + + render( + , + ); + + expect(screen.getByText("42%")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index 499c02bccb..9b28e52927 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -14,6 +14,7 @@ import { } from "../providers/ProvidersSidebar"; import { ProviderDetailPane } from "../providers/ProviderDetailPane"; import { reorderProviders } from "../../../lib/tauri"; +import { selectSingleMetricUsageWindow } from "../../../lib/usageWindows"; import { useProviders } from "../../../hooks/useProviders"; interface ProvidersTabProps { @@ -262,8 +263,7 @@ function providerSidebarMetric( snap: ProviderUsageSnapshot | null, ): string | undefined { if (!snap) return undefined; - const rate = snap.primary; - if (!rate) return undefined; + const rate = selectSingleMetricUsageWindow(snap); if (Number.isFinite(rate.usedPercent)) { return `${Math.round(Math.max(0, rate.usedPercent))}%`; }