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
45 changes: 45 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::*;
use chrono::{Local, Utc};
use serde::Serialize;
use std::sync::Arc;

const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8;
Expand Down Expand Up @@ -891,6 +893,49 @@ fn predictive_warning_identity(
Some(format!("{source}:{account}"))
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepSeekPricingStatus {
pub period: &'static str,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PricingPeriod is already a typed domain enum, but this boundary flattens it to a string; NotificationManager then stores another String, TypeScript redeclares the union, and the UI/notification matches use silent fallback branches. Please make the enum serializable with the bridge casing and pass it directly to notification logic. Keeping this exhaustive removes duplicated representations and prevents a future period from silently acquiring "standard" behavior.

pub current_local_time: String,
pub next_transition_local_time: Option<String>,
pub effective_local_time: String,
}

#[tauri::command]
pub fn get_deepseek_pricing_status(
state: tauri::State<'_, Mutex<AppState>>,
) -> Option<DeepSeekPricingStatus> {
let settings = Settings::load();
if !settings.enabled_providers.contains("deepseek") {
return None;
}
let now = Utc::now();
let schedule = codexbar::providers::deepseek::pricing::status_at(now);
let period = match schedule.period {
codexbar::providers::deepseek::pricing::PricingPeriod::Standard => "standard",
codexbar::providers::deepseek::pricing::PricingPeriod::Peak => "peak",
codexbar::providers::deepseek::pricing::PricingPeriod::OffPeak => "offPeak",
};
if let Ok(mut app_state) = state.lock() {
app_state
.notification_manager
.notify_pricing_transition(period, &settings);
}
let local = |instant: chrono::DateTime<Utc>| {
instant
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %Z")
.to_string()
};
Some(DeepSeekPricingStatus {
period,
current_local_time: Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(),
next_transition_local_time: schedule.next_transition.map(local),
effective_local_time: local(codexbar::providers::deepseek::pricing::EFFECTIVE_AT),
})
}

#[tauri::command]
pub async fn refresh_providers(app: tauri::AppHandle) -> Result<(), String> {
do_refresh_providers(&app).await
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ fn main() {
commands::refresh_providers,
commands::refresh_providers_if_stale,
commands::get_cached_providers,
commands::get_deepseek_pricing_status,
commands::codex_accounts_list,
commands::codex_account_add,
commands::codex_account_remove,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const tauriMocks = vi.hoisted(() => ({
getLocaleStrings: vi.fn(),
setUiLanguage: vi.fn(),
getCurrentSurfaceState: vi.fn(),
getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null),
}));

vi.mock("./lib/tauri", () => tauriMocks);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { FLOATBAR_WINDOW_LABEL } from "./floatbar/api";
import { LocaleProvider } from "./i18n/LocaleProvider";
import type { BootstrapState, ThemePreference } from "./types/bridge";
import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot";
import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus";

const Settings = lazy(() => import("./surfaces/Settings"));
const PopOutPanel = lazy(() => import("./surfaces/PopOutPanel"));
Expand Down Expand Up @@ -62,6 +63,7 @@ function AppInner() {
const [themePreference, setThemePreference] = useState<ThemePreference>("dark");

useTheme(themePreference);
useDeepSeekPricingStatus();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mounts the polling hook in every AppInner, so main, settings, float-bar, and detached webviews can each own a minute timer. The hook then forwards data through a window-local CustomEvent, while each DeepSeek MenuCard invokes the same command again; that "get" command also mutates notification state and can show a toast. This works, but it makes one concern a duplicated cross-layer pipeline. Please use one Rust-owned observer (sleeping until the exact next transition), keep status retrieval pure, update notifications once, and emit a typed Tauri event for UI subscribers. That removes the per-webview intervals, custom browser event, duplicate reads, and repeated settings loads.


const reloadBootstrapState = useCallback(
() => getBootstrapState(),
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop-tauri/src/components/MenuCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const tauriMocks = vi.hoisted(() => ({
getProviderChartData: vi.fn(),
getDeepSeekPricingStatus: vi.fn(),
getLocaleStrings: vi.fn(),
setUiLanguage: vi.fn(),
}));
Expand Down Expand Up @@ -133,8 +134,17 @@ describe("MenuCard", () => {
WayfinderOffline: "Gateway offline",
WayfinderDryRun: "Dry run",
WayfinderMissingKeys: "Missing keys",
DeepSeekPricingTitle: "DeepSeek pricing",
DeepSeekPricingStandard: "Standard / pre-schedule",
DeepSeekPricingPeak: "Peak hours",
DeepSeekPricingOffPeak: "Off-peak hours",
DeepSeekPricingCurrent: "Current local time:",
DeepSeekPricingNext: "Next transition:",
DeepSeekPricingEffective: "Effective local time:",
DeepSeekPricingAdvice: "Official schedule",
}),
);
tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null);
tauriMocks.getProviderChartData.mockResolvedValue({
providerId: "claude",
costHistory: [{ date: "2026-05-24", value: 1.23 }],
Expand Down Expand Up @@ -173,6 +183,24 @@ describe("MenuCard", () => {
expect(screen.queryByText("Estimated from local logs")).not.toBeInTheDocument();
});

it("shows DeepSeek peak/off-peak pricing status", 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",
});
const snapshot = provider(null);
snapshot.providerId = "deepseek";
snapshot.displayName = "DeepSeek";

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();
});

it("can render metric bars as used instead of remaining", async () => {
renderCard(provider(null, 35), { showAsUsed: true });

Expand Down
40 changes: 40 additions & 0 deletions apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type { LocaleKey } from "../i18n/keys";
import { providerSupportsChartData } from "../lib/providerCharts";
import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails";
import CodexAccountsMenu from "./CodexAccountsMenu";
import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus";
import { getDeepSeekPricingStatus } from "../lib/tauri";
import type { DeepSeekPricingStatus } from "../types/bridge";

/** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */
function CopyIconButton({ text }: { text: string }) {
Expand Down Expand Up @@ -117,6 +120,16 @@ export default function MenuCard({
} = display;
const { t } = useLocale();
const [chartData, setChartData] = useState<ProviderChartData | null>(null);
const [pricingStatus, setPricingStatus] = useState<DeepSeekPricingStatus | null>(null);

useEffect(() => {
if (provider.providerId !== "deepseek") return;
const onPricing = (event: Event) =>
setPricingStatus((event as CustomEvent<DeepSeekPricingStatus>).detail);
window.addEventListener(DEEPSEEK_PRICING_EVENT, onPricing);
void getDeepSeekPricingStatus().then(setPricingStatus).catch(() => {});
return () => window.removeEventListener(DEEPSEEK_PRICING_EVENT, onPricing);
}, [provider.providerId]);

useEffect(() => {
if (!providerSupportsChartData(provider.providerId)) {
Expand Down Expand Up @@ -249,6 +262,33 @@ export default function MenuCard({
/>
)}

{provider.providerId === "deepseek" && pricingStatus && (
<section
className="menu-card__pricing-status"
aria-label={t("DeepSeekPricingTitle")}
>
<strong>
{t("DeepSeekPricingTitle")}: {t(
pricingStatus.period === "peak"
? "DeepSeekPricingPeak"
: pricingStatus.period === "offPeak"
? "DeepSeekPricingOffPeak"
: "DeepSeekPricingStandard",
)}
</strong>
<span>
{t("DeepSeekPricingCurrent")} {pricingStatus.currentLocalTime}
</span>
<span>
{t("DeepSeekPricingNext")} {pricingStatus.nextTransitionLocalTime ?? "—"}
</span>
<span>
{t("DeepSeekPricingEffective")} {pricingStatus.effectiveLocalTime}
</span>
<small>{t("DeepSeekPricingAdvice")}</small>
</section>
)}

{provider.providerId === "codex" && (
<CodexAccountsMenu hideEmail={hideEmail} />
)}
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop-tauri/src/hooks/useDeepSeekPricingStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { getDeepSeekPricingStatus } from "../lib/tauri";
import type { DeepSeekPricingStatus } from "../types/bridge";

export const DEEPSEEK_PRICING_EVENT = "codexbar:deepseek-pricing";

export function useDeepSeekPricingStatus(): void {
useEffect(() => {
let cancelled = false;
const poll = () => {
getDeepSeekPricingStatus()
.then((status) => {
if (!cancelled && status) {
window.dispatchEvent(
new CustomEvent<DeepSeekPricingStatus>(DEEPSEEK_PRICING_EVENT, {
detail: status,
}),
);
}
})
.catch(() => {});
};
poll();
const timer = window.setInterval(poll, 60_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
}
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ export const ALL_LOCALE_KEYS = [
"ProviderSession",
"ProviderWeekly",
"ProviderMonthly",
"DeepSeekPricingTitle",
"DeepSeekPricingStandard",
"DeepSeekPricingPeak",
"DeepSeekPricingOffPeak",
"DeepSeekPricingCurrent",
"DeepSeekPricingNext",
"DeepSeekPricingEffective",
"DeepSeekPricingAdvice",
"ProviderModel",
"ProviderPlan",
"ProviderNextReset",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
CodexAccountUsageSnapshot,
CodexAccountsStateBridge,
CodexSwitchResult,
DeepSeekPricingStatus,
} from "../types/bridge";

export function getBootstrapState(): Promise<BootstrapState> {
Expand Down Expand Up @@ -124,6 +125,10 @@ export function getCachedProviders(): Promise<ProviderUsageSnapshot[]> {
return invoke<ProviderUsageSnapshot[]>("get_cached_providers");
}

export function getDeepSeekPricingStatus(): Promise<DeepSeekPricingStatus | null> {
return invoke<DeepSeekPricingStatus | null>("get_deepseek_pricing_status");
}

export function getWorkAreaRect(): Promise<WorkAreaRect> {
return invoke<WorkAreaRect>("get_work_area_rect");
}
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4171,6 +4171,31 @@ html:has(.menu-surface--tray) {
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;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const tauriMocks = vi.hoisted(() => ({
getProviderChartData: vi.fn(),
getLocaleStrings: vi.fn(),
setUiLanguage: vi.fn(),
getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null),
}));

const eventMocks = vi.hoisted(() => ({
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const tauriMocks = vi.hoisted(() => ({
getCurrentSurfaceState: vi.fn(),
getLocaleStrings: vi.fn(),
setUiLanguage: vi.fn(),
getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null),
}));

const eventMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -201,6 +202,7 @@ describe("TrayPanel provider grid", () => {
beforeEach(() => {
vi.clearAllMocks();
eventMocks.listeners.clear();
tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null);
tauriMocks.flyoutStoredSize.mockResolvedValue(null);
tauriMocks.refreshProviders.mockResolvedValue(undefined);
tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined);
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export type FloatBarStyle = "floating" | "taskbar";
export type TrayVisibilitySupport = "supported" | "unsupportedOs";
export type TrayVisibilityState = "promoted" | "notPromoted" | "entryNotFound" | "unknown";

export type DeepSeekPricingPeriod = "standard" | "peak" | "offPeak";

export interface DeepSeekPricingStatus {
period: DeepSeekPricingPeriod;
currentLocalTime: string;
nextTransitionLocalTime: string | null;
effectiveLocalTime: string;
}

export interface TrayVisibilityStatusDto {
support: TrayVisibilitySupport;
state: TrayVisibilityState;
Expand Down
8 changes: 8 additions & 0 deletions rust/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,14 @@ locale_keys! {
ProviderSession,
ProviderWeekly,
ProviderMonthly,
DeepSeekPricingTitle,
DeepSeekPricingStandard,
DeepSeekPricingPeak,
DeepSeekPricingOffPeak,
DeepSeekPricingCurrent,
DeepSeekPricingNext,
DeepSeekPricingEffective,
DeepSeekPricingAdvice,
ProviderModel,
ProviderPlan,
ProviderNextReset,
Expand Down
9 changes: 9 additions & 0 deletions rust/src/locale/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,12 @@ NetworkProxyUserLabel = Username (optional)
NetworkProxyPasswordLabel = Password (optional)
NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible.
NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port.

DeepSeekPricingTitle = DeepSeek pricing
DeepSeekPricingStandard = Standard / pre-schedule
DeepSeekPricingPeak = Peak hours
DeepSeekPricingOffPeak = Off-peak hours
DeepSeekPricingCurrent = Current local time:
DeepSeekPricingNext = Next transition:
DeepSeekPricingEffective = Effective local time:
DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price.
9 changes: 9 additions & 0 deletions rust/src/locale/es-MX.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -670,3 +670,12 @@ NetworkProxyUserLabel = Username (optional)
NetworkProxyPasswordLabel = Password (optional)
NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible.
NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port.

DeepSeekPricingTitle = DeepSeek pricing
DeepSeekPricingStandard = Standard / pre-schedule
DeepSeekPricingPeak = Peak hours
DeepSeekPricingOffPeak = Off-peak hours
DeepSeekPricingCurrent = Current local time:
DeepSeekPricingNext = Next transition:
DeepSeekPricingEffective = Effective local time:
DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price.
9 changes: 9 additions & 0 deletions rust/src/locale/ja-JP.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -736,3 +736,12 @@ NetworkProxyUserLabel = Username (optional)
NetworkProxyPasswordLabel = Password (optional)
NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible.
NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port.

DeepSeekPricingTitle = DeepSeek pricing
DeepSeekPricingStandard = Standard / pre-schedule
DeepSeekPricingPeak = Peak hours
DeepSeekPricingOffPeak = Off-peak hours
DeepSeekPricingCurrent = Current local time:
DeepSeekPricingNext = Next transition:
DeepSeekPricingEffective = Effective local time:
DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price.
Loading
Loading