Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 56 additions & 39 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
tsouth89 marked this conversation as resolved.

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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<uuid::Uuid>,
) -> Option<String> {
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]
Expand Down Expand Up @@ -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<u32>, used_percent: f64) -> RateWindowSnapshot {
Expand Down
16 changes: 2 additions & 14 deletions apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -737,20 +738,7 @@ export default function MenuCard({
/>
</div>
</div>
{provider.pace.etaSeconds != null && !provider.pace.willLastToReset && (
<div className="menu-card__pace-eta">
⚠{" "}
{t("DetailPaceRunsOutIn").replace(
"{}",
String(Math.round(provider.pace.etaSeconds / 3600)),
)}
</div>
)}
{provider.pace.willLastToReset && (
<div className="menu-card__pace-ok">
✓ {t("DetailPaceWillLastToReset")}
</div>
)}
<PaceVerdict pace={provider.pace} />
</section>
)}

Expand Down
165 changes: 165 additions & 0 deletions apps/desktop-tauri/src/components/PaceVerdict.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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<typeof import("../lib/tauri")>()),
...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> = {}): PaceSnapshot {
return {
windowLabel: "Weekly",
stage: "on_track",
deltaPercent: 0,
willLastToReset: true,
etaSeconds: null,
expectedUsedPercent: 50,
actualUsedPercent: 50,
...overrides,
};
}

function renderVerdict(snapshot: PaceSnapshot) {
return render(
<LocaleProvider>
<PaceVerdict pace={snapshot} />
</LocaleProvider>,
);
}

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<HTMLElement>(
".menu-card__pace-verdict-fill",
);
const tick = container.querySelector<HTMLElement>(
".menu-card__pace-verdict-tick",
);
expect(fill?.style.width).toBe("30%");
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 }),
);

await screen.findByText("On track");
expect(
container.querySelector<HTMLElement>(".menu-card__pace-verdict-fill")
?.style.width,
).toBe("100%");
expect(
container.querySelector<HTMLElement>(".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");
});
});
Loading
Loading