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
27 changes: 26 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
//! reads to the right cached bundle.

use codexbar::core::OpenAIDashboardCacheStore;
use codexbar::cost_scanner::{CostScanner, CostSummary, get_daily_cost_history};
use codexbar::cost_scanner::{
CostScanner, CostSummary, get_daily_cost_history, get_daily_token_history,
};
use codexbar::locale::{self, LocaleKey};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
Expand All @@ -26,6 +28,15 @@ pub struct DailyCostPoint {
pub value: f64,
}

/// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0
/// #2930 — exact local token totals).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyTokenPoint {
pub date: String,
pub tokens: u64,
}

/// A single service's usage within a day for the stacked usage breakdown chart.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -65,6 +76,10 @@ pub struct ProviderChartData {
pub credits_history: Vec<DailyCostPoint>,
pub usage_breakdown: Vec<DailyUsageBreakdown>,
pub local_usage: Option<ProviderLocalUsageSummary>,
/// Daily exact local token totals for the Tokens mode; incomplete
/// backfill keeps the marker true so the UI can show "Refreshing".
pub tokens_history: Vec<DailyTokenPoint>,
pub tokens_incomplete: bool,
}

#[tauri::command]
Expand Down Expand Up @@ -117,6 +132,12 @@ fn build_provider_chart_data_with_cancel(
.map(|(date, value)| DailyCostPoint { date, value })
.collect();

let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30);
let tokens_history: Vec<DailyTokenPoint> = raw_tokens
.into_iter()
.map(|(date, tokens)| DailyTokenPoint { date, tokens })
.collect();

let (credits_history, usage_breakdown) =
load_openai_dashboard_chart_data(&provider_id, account_email.as_deref());
let local_usage = if cancel
Expand All @@ -134,6 +155,8 @@ fn build_provider_chart_data_with_cancel(
credits_history,
usage_breakdown,
local_usage,
tokens_history,
tokens_incomplete,
}
}

Expand All @@ -145,6 +168,8 @@ impl ProviderChartData {
credits_history: Vec::new(),
usage_breakdown: Vec::new(),
local_usage: None,
tokens_history: Vec::new(),
tokens_incomplete: false,
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,9 @@ fn non_claude_error_message_is_preserved() {

#[test]
fn chart_data_serde_roundtrip_preserves_fields() {
use super::{DailyCostPoint, DailyUsageBreakdown, ProviderChartData, ServiceUsagePoint};
use super::{
DailyCostPoint, DailyTokenPoint, DailyUsageBreakdown, ProviderChartData, ServiceUsagePoint,
};

let original = ProviderChartData {
provider_id: "codex".into(),
Expand Down Expand Up @@ -1043,6 +1045,11 @@ fn chart_data_serde_roundtrip_preserves_fields() {
total_credits_used: 13.5,
}],
local_usage: None,
tokens_history: vec![DailyTokenPoint {
date: "2025-01-01".into(),
tokens: 123_456,
}],
tokens_incomplete: true,
};

let json = serde_json::to_string(&original).expect("serialize");
Expand All @@ -1056,6 +1063,9 @@ fn chart_data_serde_roundtrip_preserves_fields() {
assert!(json.contains("\"localUsage\":null"));
assert!(json.contains("\"creditsUsed\":10.0"));
assert!(json.contains("\"totalCreditsUsed\":13.5"));
assert!(json.contains("\"tokensHistory\""));
assert!(json.contains("\"tokens\":123456"));
assert!(json.contains("\"tokensIncomplete\":true"));

let back: ProviderChartData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.provider_id, "codex");
Expand All @@ -1064,6 +1074,8 @@ fn chart_data_serde_roundtrip_preserves_fields() {
assert_eq!(back.credits_history[0].value, 42.0);
assert_eq!(back.usage_breakdown[0].services.len(), 2);
assert_eq!(back.usage_breakdown[0].total_credits_used, 13.5);
assert_eq!(back.tokens_history[0].tokens, 123_456);
assert!(back.tokens_incomplete);
}

#[test]
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions apps/desktop-tauri/src/components/providers/providerIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import crossmodel from "./icons/ProviderIcon-crossmodel.svg?raw";
import cursor from "./icons/ProviderIcon-cursor.svg?raw";
import deepgram from "./icons/ProviderIcon-deepgram.svg?raw";
import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw";
import fireworks from "./icons/ProviderIcon-fireworks.svg?raw";
import aiand from "./icons/ProviderIcon-aiand.svg?raw";
import clinepass from "./icons/ProviderIcon-clinepass.svg?raw";
import longcat from "./icons/ProviderIcon-longcat.svg?raw";
Expand Down Expand Up @@ -99,6 +100,7 @@ const RAW: Record<string, string> = {
cursor: tint(cursor),
deepgram: tint(deepgram),
deepinfra: tint(deepinfra),
fireworks: tint(fireworks),
aiand: tint(aiand),
clinepass: tint(clinepass),
longcat: tint(longcat),
Expand Down Expand Up @@ -158,6 +160,7 @@ export const PROVIDER_ICON_REGISTRY: Record<string, ProviderIcon> = {
cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor },
deepgram: { id: "deepgram", brandColor: "#13ef93", fallbackLetter: "D", svgPath: RAW.deepgram },
deepinfra: { id: "deepinfra", brandColor: "#2a3275", fallbackLetter: "D", svgPath: RAW.deepinfra },
fireworks: { id: "fireworks", brandColor: "#f25b1c", fallbackLetter: "F", svgPath: RAW.fireworks },
aiand: { id: "aiand", brandColor: "#e25c2b", fallbackLetter: "&", svgPath: RAW.aiand },
clinepass: { id: "clinepass", brandColor: "#61a3fa", fallbackLetter: "C", svgPath: RAW.clinepass },
longcat: { id: "longcat", brandColor: "#ffd100", fallbackLetter: "L", svgPath: RAW.longcat },
Expand Down Expand Up @@ -246,8 +249,10 @@ const ALIASES: Record<string, string> = {
"deep seek": "deepseek",
"deep-seek": "deepseek",
"deep infra": "deepinfra",
"deep-infra": "deepinfra",
di: "deepinfra",
"deep-infra": "deepinfra",
di: "deepinfra",
"fireworks-ai": "fireworks",
fw: "fireworks",
"ai&": "aiand",
"ai-and": "aiand",
"ai and": "aiand",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,8 @@ export const ALL_LOCALE_KEYS = [
"DetailCostBalance",
"DetailCostResets",
"DetailChartCost",
"DetailChartTokens",
"DetailChartRefreshing",
"DetailChartCredits",
"DetailChartUsageBreakdown",
"DetailChartEmpty",
Expand Down
25 changes: 20 additions & 5 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2683,6 +2683,15 @@ body:has(.tray-panel-reveal) {
color: var(--provider-row-text-secondary);
}

/* Tokens chart "Refreshing" marker while local history backfill is
incomplete (upstream 0.50.0 #2930). */
.provider-detail-chart__refreshing {
margin-left: 8px;
font-size: 0.68rem;
color: var(--text-muted);
font-style: italic;
}

.chart {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -4267,14 +4276,20 @@ html:has(.menu-surface--tray) {
.menu-metric__reset {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
/* Long provider descriptions (credits remaining, etc.) used to overflow
the tray card because nowrap had no max-width/ellipsis. */
/* Upstream 0.49.0 #2742 (refs #2182): long metric reset and pace details
wrap to a second line instead of truncating, so non-English locales keep
the full reset information. #2846: the percent value keeps its own line
slot (row only stacks when both cannot fit) and the two-line clamp keeps
cached card heights bounded. */
min-width: 0;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
white-space: normal;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

.menu-metric__exhausted {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-tauri/src/surfaces/TrayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const HAS_DASHBOARD = new Set([
"mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi",
"opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun",
"t3chat", "venice", "vertexai", "warp", "windsurf",
"xai", "zai",
"xai", "zai", "fireworks",
]);
/** Provider IDs that have a status page URL in the backend */
const HAS_STATUS_PAGE = new Set([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { ChartsSection } from "./ChartsSection";
import { getProviderChartData } from "../../../../../lib/tauri";
import type { ProviderChartData } from "../../../../../types/bridge";

vi.mock("../../../../../lib/tauri", () => ({
getProviderChartData: vi.fn(),
getSettingsSnapshot: vi.fn().mockResolvedValue({ enableAnimations: false }),
}));
vi.mock("../../../../../lib/providerCharts", () => ({
providerSupportsChartData: () => true,
}));

const mockChart = vi.mocked(getProviderChartData);

function chartData(overrides: Partial<ProviderChartData>): ProviderChartData {
return {
providerId: "codex",
costHistory: [{ date: "2026-08-16", value: 1.5 }],
creditsHistory: [],
usageBreakdown: [],
localUsage: null,
tokensHistory: [{ date: "2026-08-16", tokens: 9000 }],
tokensIncomplete: false,
...overrides,
};
}

describe("ChartsSection tokens mode (upstream 0.50.0 #2930)", () => {
it("defaults Codex to the Tokens tab when exact token data exists", async () => {
mockChart.mockResolvedValue(chartData({}));
render(
<ChartsSection
providerId="codex"
accountEmail={null}
t={(key) => key}
/>,
);
await waitFor(() => {
expect(screen.getByRole("tab", { selected: true }).textContent).toBe(
"DetailChartTokens",
);
});
});

it("keeps Cost as the default for non-Codex providers", async () => {
mockChart.mockResolvedValue(chartData({ providerId: "claude" }));
render(
<ChartsSection
providerId="claude"
accountEmail={null}
t={(key) => key}
/>,
);
await waitFor(() => {
expect(screen.getByRole("tab", { selected: true }).textContent).toBe(
"DetailChartCost",
);
});
});

it("shows the Refreshing marker while local history backfill is incomplete", async () => {
mockChart.mockResolvedValue(chartData({ tokensIncomplete: true }));
render(
<ChartsSection
providerId="codex"
accountEmail={null}
t={(key) => key}
/>,
);
await waitFor(() => {
expect(screen.getByText("DetailChartRefreshing")).toBeTruthy();
});
});

it("hides the Tokens tab when no day carries token data", async () => {
mockChart.mockResolvedValue(
chartData({ tokensHistory: [{ date: "2026-08-16", tokens: 0 }] }),
);
render(
<ChartsSection
providerId="codex"
accountEmail={null}
t={(key) => key}
/>,
);
await waitFor(() => {
expect(screen.getByRole("tab", { selected: true }).textContent).toBe(
"DetailChartCost",
);
});
expect(screen.queryByText("DetailChartTokens")).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ProviderChartData, SettingsSnapshot } from "../../../../../types/b
import type { useLocale } from "../../../../../hooks/useLocale";
import { CostHistoryChart } from "./CostHistoryChart";
import { CreditsHistoryChart } from "./CreditsHistoryChart";
import { TokensHistoryChart } from "./TokensHistoryChart";
import { UsageBreakdownChart } from "./UsageBreakdownChart";

type T = ReturnType<typeof useLocale>["t"];
Expand All @@ -15,7 +16,7 @@ interface Props {
t: T;
}

type TabKey = "cost" | "credits" | "usage";
type TabKey = "tokens" | "cost" | "credits" | "usage";

/**
* Charts tabs block for the Settings → Providers detail pane.
Expand Down Expand Up @@ -73,18 +74,26 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) {
const hasCost = data.costHistory.length > 0;
const hasCredits = data.creditsHistory.length > 0;
const hasUsage = data.usageBreakdown.length > 0;
// 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) return null;
if (!hasCost && !hasCredits && !hasUsage && !hasTokens) return null;

const available: TabKey[] = [];
if (hasCost) available.push("cost");
if (hasCredits) available.push("credits");
if (hasUsage) available.push("usage");
if (hasTokens) available.push("tokens");

const current: TabKey = active && available.includes(active) ? active : available[0];
// Upstream 0.50.0 #2930: Codex defaults to exact local token totals.
const defaultTab: TabKey =
providerId === "codex" && hasTokens ? "tokens" : available[0];
const current: TabKey =
active && available.includes(active) ? active : defaultTab;
const emptyMsg = t("DetailChartEmpty");

const tabLabel = (k: TabKey): string => {
if (k === "tokens") return t("DetailChartTokens");
if (k === "cost") return t("DetailChartCost");
if (k === "credits") return t("DetailChartCredits");
return t("DetailChartUsageBreakdown");
Expand All @@ -108,6 +117,18 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) {
))}
</div>
<div className="provider-detail-charts__body" role="tabpanel">
{current === "tokens" && (
<TokensHistoryChart
data={data.tokensHistory}
title={t("DetailChartTokens")}
ariaLabel={t("DetailChartTokens")}
providerId={providerId}
animations={animations}
emptyMessage={emptyMsg}
incomplete={data.tokensIncomplete}
t={t}
/>
)}
{current === "cost" && (
<CostHistoryChart
data={data.costHistory}
Expand Down
Loading
Loading