From 9e1ad8ef115ddf3cd35fced8cc230f8cf59a3fa9 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 4 Aug 2026 21:15:18 -0400 Subject: [PATCH 1/2] Add a pace verdict and re-expose predictive warnings (#190) Ceiling already predicted whether usage would outlast its window, but the answer was unreachable: the detail section is hidden in the tray surface, and the warning that fires on a predicted shortfall had been retired as experimental, pinned off on every settings load with no UI to enable it. Visual: - Add `PaceVerdict`: a headline, its consequence, and one slim bar whose tick marks where usage should be by this point in the window. It carries the same conclusion as the expected-vs-actual pair at roughly a third of the height, which is what made the old section too heavy for the tray. - Show it in the tray and keep the taller breakdown for the window view. - A pace that still runs out early never renders in the calm "slow" colour, since the shortfall is the part that matters. Warning: - Stop pinning `predictive_pace_warning_enabled` to false on load and restore its toggle under Settings > Notifications. It stays opt-in. - Let any provider raise the warning. It was restricted to Claude and Codex, and providers that never report an account were skipped entirely for want of a dedupe identity. - Name the window by its real cadence. Warnings were labelled by slot, so a monthly quota would have announced itself as a "Session" limit. --- .../src-tauri/src/commands/providers.rs | 95 ++++++----- .../desktop-tauri/src/components/MenuCard.tsx | 16 +- .../src/components/PaceVerdict.test.tsx | 151 ++++++++++++++++++ .../src/components/PaceVerdict.tsx | 84 ++++++++++ apps/desktop-tauri/src/i18n/keys.ts | 6 + apps/desktop-tauri/src/styles.css | 76 ++++++++- .../settings/tabs/GeneralTab.test.tsx | 28 +++- .../src/surfaces/settings/tabs/GeneralTab.tsx | 12 ++ rust/src/locale.rs | 6 + rust/src/locale/en-US.ftl | 8 +- rust/src/locale/zh-CN.ftl | 8 +- rust/src/notifications.rs | 2 + rust/src/settings.rs | 3 +- rust/src/settings/raw.rs | 5 +- rust/src/settings/tests.rs | 14 ++ 15 files changed, 450 insertions(+), 64 deletions(-) create mode 100644 apps/desktop-tauri/src/components/PaceVerdict.test.tsx create mode 100644 apps/desktop-tauri/src/components/PaceVerdict.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 29ef3687..20347364 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -960,7 +960,7 @@ fn notify_predictive_pace( ) { let enabled = settings.show_notifications && settings.predictive_pace_warning_enabled; manager.set_predictive_warnings_enabled(provider, enabled); - if !enabled || !matches!(provider, ProviderId::Claude | ProviderId::Codex) { + if !enabled { return; } @@ -969,7 +969,6 @@ fn notify_predictive_pace( .and_then(ProviderAccountData::active_account) .map(|account| account.id); let Some(identity) = predictive_warning_identity( - provider, &snapshot.source_label, snapshot.account_email.as_deref(), token_account_id, @@ -980,21 +979,27 @@ fn notify_predictive_pace( .ok() .map(|date| date.with_timezone(&chrono::Utc)); - for (warning_window, window, default_window_minutes) in [ + for (fallback_window, window, default_window_minutes) in [ ( codexbar::notifications::PredictiveWarningWindow::Session, Some(&snapshot.primary), - 300, + 300u32, ), ( codexbar::notifications::PredictiveWarningWindow::Weekly, snapshot.secondary.as_ref(), - 10080, + 10080u32, ), ] { let Some(window) = window else { continue; }; + // Name the window by its real cadence; fall back to the slot's meaning + // only when the provider does not state a duration. + let warning_window = window + .window_minutes + .map(predictive_window_for) + .unwrap_or(fallback_window); let rate_window = RateWindow::with_details( window.used_percent, window.window_minutes, @@ -1021,24 +1026,41 @@ fn notify_predictive_pace( } } +/// Identifies the account a pace warning belongs to, so a warning fires once +/// per cycle per account rather than once per refresh. +/// +/// The dedupe key already carries the provider, so this only has to separate +/// accounts within one. Providers that never report an account are still +/// warnable: one unnamed account is still an account. fn predictive_warning_identity( - provider: ProviderId, source_label: &str, account_email: Option<&str>, token_account_id: Option, ) -> Option { - if !matches!(provider, ProviderId::Claude | ProviderId::Codex) { - return None; - } if let Some(id) = token_account_id { return Some(format!("token-account:{}", id.as_hyphenated())); } let source = source_label.trim().to_ascii_lowercase(); - let account = account_email?.trim().to_ascii_lowercase(); - if source.is_empty() || account.is_empty() { - return None; + let account = account_email + .map(|email| email.trim().to_ascii_lowercase()) + .filter(|email| !email.is_empty()); + match (source.is_empty(), account) { + (false, Some(account)) => Some(format!("{source}:{account}")), + (false, None) => Some(source), + (true, Some(account)) => Some(account), + (true, None) => None, + } +} + +/// Which cadence a window represents, so the warning names the right thing. +/// Without this a monthly quota would be announced as a "Session" limit. +fn predictive_window_for(window_minutes: u32) -> codexbar::notifications::PredictiveWarningWindow { + use codexbar::notifications::PredictiveWarningWindow as W; + match window_minutes { + 0..=720 => W::Session, + 721..=20_160 => W::Weekly, + _ => W::Monthly, } - Some(format!("{source}:{account}")) } #[tauri::command] @@ -1076,47 +1098,42 @@ mod predictive_warning_tests { let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "cli", - Some("Person@Example.com"), - None, - ) - .as_deref(), + predictive_warning_identity("cli", Some("Person@Example.com"), None).as_deref(), Some("cli:person@example.com") ); assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - None, - ) - .as_deref(), + predictive_warning_identity("oauth", Some("Person@Example.com"), None).as_deref(), Some("oauth:person@example.com") ); assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - Some(account_id), - ) - .as_deref(), + predictive_warning_identity("oauth", Some("Person@Example.com"), Some(account_id)) + .as_deref(), Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ); } #[test] - fn predictive_warning_identity_skips_unidentified_accounts() { + fn predictive_warning_identity_falls_back_to_the_source_alone() { + // Providers that never report an account still get one stable identity, + // so they can be warned instead of silently skipped. assert_eq!( - predictive_warning_identity(ProviderId::Claude, "oauth", None, None), - None + predictive_warning_identity("oauth", None, None).as_deref(), + Some("oauth") ); assert_eq!( - predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None), - None + predictive_warning_identity("cli", Some(" "), None).as_deref(), + Some("cli") ); + assert_eq!(predictive_warning_identity(" ", None, None), None); + } + + #[test] + fn predictive_window_is_named_by_its_real_cadence() { + use codexbar::notifications::PredictiveWarningWindow as W; + assert_eq!(predictive_window_for(300), W::Session); + assert_eq!(predictive_window_for(10_080), W::Weekly); + // A monthly quota must not be announced as a session limit. + assert_eq!(predictive_window_for(44_640), W::Monthly); } fn rw(window_minutes: Option, used_percent: f64) -> RateWindowSnapshot { diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index d0e2a7d0..78cf3f12 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -16,6 +16,7 @@ import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { providerSupportsChartData } from "../lib/providerCharts"; import { getPaceBudget } from "../lib/paceBudget"; +import PaceVerdict from "./PaceVerdict"; import { activePromoBoosts, activePromoInclusions, @@ -737,20 +738,7 @@ export default function MenuCard({ /> - {provider.pace.etaSeconds != null && !provider.pace.willLastToReset && ( -
- ⚠{" "} - {t("DetailPaceRunsOutIn").replace( - "{}", - String(Math.round(provider.pace.etaSeconds / 3600)), - )} -
- )} - {provider.pace.willLastToReset && ( -
- ✓ {t("DetailPaceWillLastToReset")} -
- )} + )} diff --git a/apps/desktop-tauri/src/components/PaceVerdict.test.tsx b/apps/desktop-tauri/src/components/PaceVerdict.test.tsx new file mode 100644 index 00000000..5cb58e4d --- /dev/null +++ b/apps/desktop-tauri/src/components/PaceVerdict.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getLocaleStrings: vi.fn(), + setUiLanguage: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ listen: vi.fn() })); + +vi.mock("../lib/tauri", async (importOriginal) => ({ + ...(await importOriginal()), + ...tauriMocks, +})); +vi.mock("@tauri-apps/api/event", () => eventMocks); + +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { buildBundle } from "../test/localeHarness"; +import type { PaceSnapshot } from "../types/bridge"; +import PaceVerdict, { formatEta } from "./PaceVerdict"; + +function pace(overrides: Partial = {}): PaceSnapshot { + return { + windowLabel: "Weekly", + stage: "on_track", + deltaPercent: 0, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 50, + actualUsedPercent: 50, + ...overrides, + }; +} + +function renderVerdict(snapshot: PaceSnapshot) { + return render( + + + , + ); +} + +describe("PaceVerdict", () => { + beforeEach(() => { + vi.clearAllMocks(); + eventMocks.listen.mockResolvedValue(() => {}); + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ + PaceVerdictOnTrack: "On track", + PaceVerdictAhead: "Ahead of pace", + PaceVerdictPlenty: "Plenty left", + PaceVerdictRunningOut: "Running out early", + PaceVerdictRunsOutIn: "Runs out in about {} at this pace", + PaceVerdictLastsToReset: "Lasts to reset, {}% to spare", + }), + ); + }); + + it("reports how much is left when usage lasts to reset", async () => { + renderVerdict(pace({ actualUsedPercent: 74 })); + + expect(await screen.findByText("On track")).toBeInTheDocument(); + expect(screen.getByText("Lasts to reset, 26% to spare")).toBeInTheDocument(); + }); + + it("leads with the shortfall when usage runs out first", async () => { + renderVerdict( + pace({ + stage: "far_ahead", + willLastToReset: false, + etaSeconds: 2 * 24 * 3600, + actualUsedPercent: 82, + expectedUsedPercent: 41, + }), + ); + + expect(await screen.findByText("Running out early")).toBeInTheDocument(); + expect( + screen.getByText("Runs out in about 2d at this pace"), + ).toBeInTheDocument(); + }); + + it("keeps a warning tone when a slow pace still runs out early", async () => { + // Behind pace but still exhausting before reset: the shortfall is what + // matters, so this must not render in the calm 'slow' colour. + const { container } = renderVerdict( + pace({ + stage: "far_behind", + willLastToReset: false, + etaSeconds: 3600, + actualUsedPercent: 30, + }), + ); + + await screen.findByText("Running out early"); + expect( + container.querySelector(".menu-card__pace-verdict-title"), + ).toHaveAttribute("data-pace", "racing"); + }); + + it("places the tick at expected usage and the fill at actual", async () => { + const { container } = renderVerdict( + pace({ + stage: "far_behind", + actualUsedPercent: 30, + expectedUsedPercent: 60, + }), + ); + + await screen.findByText("Plenty left"); + const fill = container.querySelector( + ".menu-card__pace-verdict-fill", + ); + const tick = container.querySelector( + ".menu-card__pace-verdict-tick", + ); + expect(fill?.style.width).toBe("30%"); + expect(tick?.style.left).toBe("60%"); + }); + + it("clamps out-of-range percentages instead of overflowing the track", async () => { + const { container } = renderVerdict( + pace({ actualUsedPercent: 140, expectedUsedPercent: -20 }), + ); + + await screen.findByText("On track"); + expect( + container.querySelector(".menu-card__pace-verdict-fill") + ?.style.width, + ).toBe("100%"); + expect( + container.querySelector(".menu-card__pace-verdict-tick") + ?.style.left, + ).toBe("0%"); + }); +}); + +describe("formatEta", () => { + it("formats coarse durations without noise", () => { + expect(formatEta(45 * 60)).toBe("45m"); + expect(formatEta(3 * 3600)).toBe("3h"); + expect(formatEta(3 * 3600 + 25 * 60)).toBe("3h 25m"); + expect(formatEta(2 * 24 * 3600)).toBe("2d"); + expect(formatEta(2 * 24 * 3600 + 5 * 3600)).toBe("2d 5h"); + }); + + it("does not render negative or non-finite time", () => { + expect(formatEta(-10)).toBe("0m"); + expect(formatEta(Number.NaN)).toBe("0m"); + }); +}); diff --git a/apps/desktop-tauri/src/components/PaceVerdict.tsx b/apps/desktop-tauri/src/components/PaceVerdict.tsx new file mode 100644 index 00000000..1d73072a --- /dev/null +++ b/apps/desktop-tauri/src/components/PaceVerdict.tsx @@ -0,0 +1,84 @@ +import { useLocale } from "../i18n/LocaleProvider"; +import { paceCategory } from "../surfaces/tray/paceCategory"; +import type { PaceSnapshot } from "../types/bridge"; + +/** + * The one-line answer to "am I going to run out before this resets?". + * + * The detailed expected-vs-actual bars are too tall for the tray, so they stay + * hidden there. This carries the same conclusion in a third of the height: a + * verdict, its consequence, and a single bar whose tick marks where usage + * should be by now. + */ +export default function PaceVerdict({ pace }: { pace: PaceSnapshot }) { + const { t } = useLocale(); + const category = paceCategory(pace.stage); + const runningOut = !pace.willLastToReset && pace.etaSeconds != null; + // Running out dominates: being "slow" is irrelevant if the meter still dies + // before the window closes. + const tone = runningOut && category !== "burning" ? "racing" : category; + + const remaining = Math.max(0, Math.round(100 - pace.actualUsedPercent)); + const headline = runningOut + ? t("PaceVerdictRunningOut") + : t( + category === "burning" || category === "racing" + ? "PaceVerdictAhead" + : category === "slow" + ? "PaceVerdictPlenty" + : "PaceVerdictOnTrack", + ); + const detail = runningOut + ? t("PaceVerdictRunsOutIn").replace( + "{}", + formatEta(pace.etaSeconds as number), + ) + : t("PaceVerdictLastsToReset").replace("{}", String(remaining)); + + const actual = clampPercent(pace.actualUsedPercent); + const expected = clampPercent(pace.expectedUsedPercent); + + return ( +
+
+ + + {headline} + +
+ {detail} +
+
+
+
+
+ ); +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +/** Compact duration for the "runs out in X" line. */ +export function formatEta(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0m"; + const totalMinutes = Math.round(seconds / 60); + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + return `${minutes}m`; +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 1cfe001e..c0e43aa0 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -497,6 +497,12 @@ export const ALL_LOCALE_KEYS = [ "PanelLeftSuffix", "PanelOnPaceBudget", "PanelAmountOf", + "PaceVerdictOnTrack", + "PaceVerdictAhead", + "PaceVerdictPlenty", + "PaceVerdictRunningOut", + "PaceVerdictRunsOutIn", + "PaceVerdictLastsToReset", "PanelNow", "PanelOneHour", "PanelFiveHours", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 7d09eca5..237cc8d4 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4217,9 +4217,10 @@ html:has(.menu-surface--tray) { font-size: 10px; } /* Hide pace section in tray — macOS doesn't show pace in the compact popover */ -.menu-surface--tray .menu-card__pace, -.menu-surface--tray .menu-card__pace + .menu-card__divider, -.menu-surface--tray .menu-card__content > .menu-card__divider:has(+ .menu-card__pace) { +/* The tray keeps the pace verdict but drops the taller expected-vs-actual + breakdown, which is what made this section too heavy for the tray before. */ +.menu-surface--tray .menu-card__pace-header, +.menu-surface--tray .menu-card__pace-bars { display: none; } /* Hide large charts in tray; keep the compact local token/cost summary visible. */ @@ -5050,6 +5051,75 @@ html:has(.menu-surface--tray) { .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-verdict { + display: flex; + flex-direction: column; + gap: 3px; +} + +.menu-card__pace-verdict-head { + display: flex; + align-items: center; + gap: 6px; +} + +.menu-card__pace-verdict-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex: none; +} + +.menu-card__pace-verdict-title { + font-size: 12.5px; + font-weight: 600; +} + +.menu-card__pace-verdict-detail { + font-size: 11px; + color: var(--text-secondary); +} + +.menu-card__pace-verdict-track { + position: relative; + height: 4px; + margin-top: 2px; + border-radius: 2px; + background: var(--usage-bar-track); +} + +.menu-card__pace-verdict-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Where usage should be by this point in the window. */ +.menu-card__pace-verdict-tick { + position: absolute; + top: -3px; + width: 1.5px; + height: 10px; + border-radius: 1px; + background: var(--text-primary); + opacity: 0.75; + transform: translateX(-50%); +} + +.menu-card__pace-verdict-dot[data-pace="slow"], +.menu-card__pace-verdict-fill[data-pace="slow"] { background: var(--pace-slow-fg); } +.menu-card__pace-verdict-dot[data-pace="steady"], +.menu-card__pace-verdict-fill[data-pace="steady"] { background: var(--pace-steady-fg); } +.menu-card__pace-verdict-dot[data-pace="racing"], +.menu-card__pace-verdict-fill[data-pace="racing"] { background: var(--pace-racing-fg); } +.menu-card__pace-verdict-dot[data-pace="burning"], +.menu-card__pace-verdict-fill[data-pace="burning"] { background: var(--pace-burning-fg); } + +.menu-card__pace-verdict-title[data-pace="slow"] { color: var(--pace-slow-fg); } +.menu-card__pace-verdict-title[data-pace="steady"] { color: var(--pace-steady-fg); } +.menu-card__pace-verdict-title[data-pace="racing"] { color: var(--pace-racing-fg); } +.menu-card__pace-verdict-title[data-pace="burning"] { color: var(--pace-burning-fg); } + .menu-card__pace-bars { display: flex; flex-direction: column; diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 6063a1cf..6f630b48 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -212,7 +212,6 @@ describe("GeneralTab", () => { ); expect(screen.getAllByRole("spinbutton")).toHaveLength(3); - expect(screen.queryByText("PredictivePaceWarnings")).not.toBeInTheDocument(); expect(screen.queryByText("CriticalUsageAlert")).not.toBeInTheDocument(); expect(screen.queryByText("Codex · ProviderSession")).not.toBeInTheDocument(); @@ -220,6 +219,33 @@ describe("GeneralTab", () => { expect(set).toHaveBeenCalledWith({ highUsageThreshold: 80 }); }); + it("offers predictive pace warnings as an opt-in", () => { + const set = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("checkbox", { name: "PredictivePaceWarnings" }); + expect(toggle).not.toBeChecked(); + fireEvent.click(toggle); + expect(set).toHaveBeenCalledWith({ predictivePaceWarningEnabled: true }); + }); + + it("disables predictive pace warnings when notifications are off", () => { + render( + , + ); + + expect( + screen.getByRole("checkbox", { name: "PredictivePaceWarnings" }), + ).toBeDisabled(); + }); + it("configures a daily or month-to-date estimated API value budget", () => { const set = vi.fn(); render( diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index d8a0c74c..c7b49f9a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -124,6 +124,18 @@ export default function GeneralTab({ onChange={(v) => set({ capacityEventNotificationsEnabled: v })} /> + + set({ predictivePaceWarningEnabled: v })} + /> + LocaleKey::ProviderSession, Self::Weekly => LocaleKey::ProviderWeekly, + Self::Monthly => LocaleKey::ProviderMonthly, }, ) } diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 5f364813..01be2897 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -130,7 +130,8 @@ pub struct Settings { #[serde(default)] pub show_reset_when_exhausted: bool, - /// Warn when Codex or Claude pace predicts exhaustion before reset. + /// Warn when a provider's pace predicts exhaustion before its reset. + /// Opt-in: this is a prediction, so it stays off until asked for. #[serde(default)] pub predictive_pace_warning_enabled: bool, diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 683508f9..9e99bdb6 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -528,10 +528,7 @@ impl From for Settings { enable_animations: raw.enable_animations, reset_time_relative: raw.reset_time_relative, show_reset_when_exhausted: raw.show_reset_when_exhausted, - // Predictive warnings were experimental and are no longer exposed. - // Keep the serialized field for compatibility, but do not leave a - // hidden alert source enabled after upgrading. - predictive_pace_warning_enabled: false, + predictive_pace_warning_enabled: raw.predictive_pace_warning_enabled, menu_bar_display_mode: raw.menu_bar_display_mode, show_all_token_accounts_in_menu: raw.show_all_token_accounts_in_menu, provider_configs, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 518bee51..7a7da3b8 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -34,6 +34,20 @@ fn new_warning_and_reset_settings_are_backward_compatible() { assert!(loaded.capacity_event_notifications_enabled); } +#[test] +fn predictive_pace_warning_opt_in_survives_the_raw_round_trip() { + // This was previously pinned to false on every load, which made the + // setting unreachable. Opting in must now stick. + let s = Settings { + predictive_pace_warning_enabled: true, + ..Settings::default() + }; + + let json = serde_json::to_string(&s).expect("serialize"); + let back: Settings = serde_json::from_str(&json).expect("deserialize"); + assert!(back.predictive_pace_warning_enabled); +} + #[test] fn raw_settings_clamp_spend_budget_warning_to_cap() { let loaded: Settings = serde_json::from_str( From 20fa394ea84f62b3f2bc4a65b293e2318dbc9494 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Tue, 4 Aug 2026 21:55:52 -0400 Subject: [PATCH 2/2] Address review: remove the last provider gate, clamp before formatting - Drop the Claude/Codex check in `record_predictive_observation`. Two other gates were removed for #190, but this third one sat in the dedupe path, so enabling the setting would still have warned nobody else. The identity check stays: a warning with no account to attribute it to is dropped. - Derive the spare-capacity figure in `PaceVerdict` from the clamped percentage. A non-finite value off the bridge rendered "NaN% to spare" next to a bar that had already clamped it away. --- .../src/components/PaceVerdict.test.tsx | 14 +++++ .../src/components/PaceVerdict.tsx | 9 ++-- rust/src/notifications.rs | 51 ++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/desktop-tauri/src/components/PaceVerdict.test.tsx b/apps/desktop-tauri/src/components/PaceVerdict.test.tsx index 5cb58e4d..b82f184b 100644 --- a/apps/desktop-tauri/src/components/PaceVerdict.test.tsx +++ b/apps/desktop-tauri/src/components/PaceVerdict.test.tsx @@ -118,6 +118,20 @@ describe("PaceVerdict", () => { expect(tick?.style.left).toBe("60%"); }); + it("never renders NaN in the spare-capacity line", async () => { + // The bar already clamped these; the copy must agree with it rather than + // rendering "NaN% to spare" beside a clamped bar. + renderVerdict(pace({ actualUsedPercent: Number.NaN })); + + expect(await screen.findByText("Lasts to reset, 100% to spare")).toBeInTheDocument(); + }); + + it("reports no spare capacity when usage overshoots the window", async () => { + renderVerdict(pace({ actualUsedPercent: 140 })); + + expect(await screen.findByText("Lasts to reset, 0% to spare")).toBeInTheDocument(); + }); + it("clamps out-of-range percentages instead of overflowing the track", async () => { const { container } = renderVerdict( pace({ actualUsedPercent: 140, expectedUsedPercent: -20 }), diff --git a/apps/desktop-tauri/src/components/PaceVerdict.tsx b/apps/desktop-tauri/src/components/PaceVerdict.tsx index 1d73072a..fddac426 100644 --- a/apps/desktop-tauri/src/components/PaceVerdict.tsx +++ b/apps/desktop-tauri/src/components/PaceVerdict.tsx @@ -18,7 +18,11 @@ export default function PaceVerdict({ pace }: { pace: PaceSnapshot }) { // before the window closes. const tone = runningOut && category !== "burning" ? "racing" : category; - const remaining = Math.max(0, Math.round(100 - pace.actualUsedPercent)); + const actual = clampPercent(pace.actualUsedPercent); + const expected = clampPercent(pace.expectedUsedPercent); + // Derive from the clamped value, or a NaN off the bridge would render as + // "NaN% to spare" beside a bar that had already clamped it away. + const remaining = Math.round(100 - actual); const headline = runningOut ? t("PaceVerdictRunningOut") : t( @@ -35,9 +39,6 @@ export default function PaceVerdict({ pace }: { pace: PaceSnapshot }) { ) : t("PaceVerdictLastsToReset").replace("{}", String(remaining)); - const actual = clampPercent(pace.actualUsedPercent); - const expected = clampPercent(pace.expectedUsedPercent); - return (
diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index 3fe502c0..b29b5d70 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -379,7 +379,9 @@ impl NotificationManager { .retain(|key| key.provider != provider); return false; } - if !matches!(provider, ProviderId::Claude | ProviderId::Codex) || identity.is_empty() { + // Any provider that reports a reset can be paced; the caller supplies + // the identity that separates accounts within one. + if identity.is_empty() { return false; } let Some(resets_at) = rate_window.resets_at else { @@ -1559,6 +1561,53 @@ mod tests { )); } + #[test] + fn any_provider_can_raise_a_predictive_warning() { + // This was gated to Claude and Codex, so enabling the setting could + // never warn a Cursor or Copilot user no matter what their pace said. + let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let window = window(now, Duration::days(3), 10080); + let risk = pace(false, Some(3600.0)); + let recovery = pace(true, None); + let mut manager = NotificationManager::new_armed(); + + // First reading is the silent baseline, as for every provider. + assert!(!manager.record_predictive_observation( + true, + ProviderId::Cursor, + "web", + PredictiveWarningWindow::Monthly, + &window, + &recovery, + )); + // The second confirms it and must be allowed through. + assert!(manager.record_predictive_observation( + true, + ProviderId::Cursor, + "web", + PredictiveWarningWindow::Monthly, + &window, + &risk, + )); + } + + #[test] + fn predictive_warnings_still_need_an_identity() { + let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let window = window(now, Duration::days(3), 10080); + let risk = pace(false, Some(3600.0)); + let mut manager = NotificationManager::new_armed(); + + assert!(!manager.record_predictive_observation( + true, + ProviderId::Cursor, + "", + PredictiveWarningWindow::Monthly, + &window, + &risk, + )); + } + #[test] fn first_predictive_risk_is_a_silent_baseline() { let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap();