From cd3e84f4ecfaa5e4ce0ba4a23f3047327eec3494 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 19 Oct 2026 02:09:48 +0700 Subject: [PATCH 1/3] Add OpenAI Live and Spend chart switch --- CHANGELOG.md | 1 + .../scripts/proof-scenarios.json | 21 +++ .../desktop-tauri/src/components/MenuCard.tsx | 9 +- .../components/MenuCardOpenAIUsage.test.tsx | 27 +++- .../src/components/OpenAIApiUsageChart.tsx | 11 +- .../src/components/OpenAILiveSpendChart.tsx | 111 ++++++++++++++ .../OpenAITokensPerMinuteChart.test.tsx | 75 ++++++++- .../components/OpenAITokensPerMinuteChart.tsx | 61 ++++---- .../src/components/charts/ScrubBarStrip.tsx | 143 ++++++++++++++++++ apps/desktop-tauri/src/i18n/keyGroups/core.ts | 7 + .../desktop-tauri/src/lib/openaiSpend.test.ts | 73 +++++++++ apps/desktop-tauri/src/lib/openaiSpend.ts | 61 ++++++++ .../desktop-tauri/src/lib/openaiThroughput.ts | 12 ++ .../src/proof/fixtures/common.ts | 1 + .../src/proof/fixtures/scenarios.ts | 84 ++++++++++ .../src/styles/provider-details.css | 60 ++++++++ rust/src/locale.rs | 7 + rust/src/locale/en-US.ftl | 7 + rust/src/locale/es-MX.ftl | 7 + rust/src/locale/ja-JP.ftl | 7 + rust/src/locale/ko-KR.ftl | 7 + rust/src/locale/pt-BR.ftl | 7 + rust/src/locale/ru-RU.ftl | 7 + rust/src/locale/tr-TR.ftl | 7 + rust/src/locale/uk-UA.ftl | 7 + rust/src/locale/zh-CN.ftl | 7 + rust/src/locale/zh-TW.ftl | 7 + 27 files changed, 787 insertions(+), 47 deletions(-) create mode 100644 apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx create mode 100644 apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx create mode 100644 apps/desktop-tauri/src/lib/openaiSpend.test.ts create mode 100644 apps/desktop-tauri/src/lib/openaiSpend.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 02923e6b186..01759332b72 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Localization: align the Raycast auto-import and cookie-disabled strings in ru-RU, uk-UA, tr-TR and zh-CN with each locale's existing cookie and installed-browser terminology. - OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The menu bar token comes in a later release (upstream #4495). - OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495). +- OpenAI API: the card adds a **Live · Spend** switch when it has both the Tokens/min window and daily spend history. Live (the default) shows the Tokens/min grid and Spend shows daily spend for the history window; the card keeps the same height in both modes, and the choice is remembered. Hovering, dragging across, or arrowing through either chart selects one bar and shows its minute (tokens and requests, or "reporting…" for pending minutes) or its day (cost and tokens) below the chart (upstream #4495). - Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436). - Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 5143620ee85..a3cb06b787d 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -99,6 +99,27 @@ { "screenshot": "settings-menu-work-days-6" } ] }, + { + "name": "tray-openai-live-spend", + "query": "window=main&fixture=openai-live-spend", + "size": [310, 1032], + "steps": [ + { "expectText": ["OpenAI API", "Tokens per minute", "Live", "Spend"] }, + { "expectSelector": ".openai-live-spend[data-mode=\"live\"] [data-testid=\"openai-spend-layer\"][aria-hidden=\"true\"]" }, + { "screenshot": "tray-openai-live", "clip": "panel" }, + { "click": { "selector": ".openai-tpm__chart", "label": "Tokens per minute" } }, + { "expectText": "requests" }, + { "expectSelector": "[data-testid=\"openai-live-layer\"] rect.openai-tpm__bar--selected" }, + { "screenshot": "tray-openai-live-inspect", "clip": "panel" }, + { "click": { "selector": "button", "label": "Spend" } }, + { "expectText": "Daily spend · Last 30 days" }, + { "expectSelector": ".openai-live-spend[data-mode=\"spend\"] [data-testid=\"openai-live-layer\"][aria-hidden=\"true\"]" }, + { "click": { "selector": ".openai-tpm__chart", "label": "Daily spend · Last 30 days" } }, + { "expectText": ": $" }, + { "expectSelector": "[data-testid=\"openai-spend-layer\"] rect.openai-tpm__bar--selected" }, + { "screenshot": "tray-openai-spend-inspect", "clip": "panel" } + ] + }, { "name": "floatbar", "query": "window=floatbar", diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index d9953e60439..fa5b940a5d2 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -26,7 +26,7 @@ import DetailSections from "./card/DetailSections"; import InlineDashboard from "./card/InlineDashboard"; import ExtraUsageBlock from "./card/ExtraUsageBlock"; import WayfinderBlock from "./card/WayfinderBlock"; -import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; +import { OpenAILiveSpendChart } from "./OpenAILiveSpendChart"; import { describeCard } from "./card/describeCard"; import { hideEmailsInText } from "../lib/personalInfo"; @@ -299,7 +299,12 @@ export default function MenuCard({ )} {presence.wayfinderUsage && } {presence.openAiThroughput && ( - + )} {dashboard && } {showCost && provider.cost && ( diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx index 2aa6cc58b39..9ac9fd59760 100644 --- a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -20,6 +20,7 @@ vi.mock("../lib/tauri", async (importOriginal) => ({ vi.mock("@tauri-apps/api/event", () => eventMocks); import { LocaleProvider } from "../i18n/LocaleProvider"; +import { PROVIDER_CHART_MODES_STORAGE_KEY } from "../lib/openaiSpend"; import { buildBundle } from "../test/localeHarness"; import type { OpenAiApiUsageSnapshot, ProviderUsageSnapshot } from "../types/bridge"; import MenuCard from "./MenuCard"; @@ -138,6 +139,30 @@ describe("MenuCard OpenAI daily usage", () => { expect(container.querySelector(".openai-tpm")).not.toBeNull(); }); + it("offers the Live · Spend switch only when both charts have points", async () => { + window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY); + const onlyLive = renderCard(withThroughput(snapshot("openaiapi", null))); + await waitFor(() => expect(onlyLive.container.querySelector(".openai-tpm")).not.toBeNull()); + expect(onlyLive.container.querySelector(".openai-live-spend__switch")).toBeNull(); + onlyLive.unmount(); + + const { container } = renderCard(withThroughput(snapshot("openaiapi", usage()))); + await waitFor(() => expect(container.querySelector(".openai-live-spend__switch")).not.toBeNull()); + const [live, spend] = Array.from(container.querySelectorAll('[role="radio"]')); + expect(live).toHaveAttribute("aria-checked", "true"); + expect(container.querySelector('[data-testid="openai-spend-layer"]')).toHaveAttribute("aria-hidden", "true"); + + fireEvent.click(spend); + expect(spend).toHaveAttribute("aria-checked", "true"); + expect(container.querySelector('[data-testid="openai-live-layer"]')).toHaveAttribute("aria-hidden", "true"); + expect(container.querySelector('[data-testid="openai-spend-layer"]')).toHaveAttribute("aria-hidden", "false"); + expect(container).toHaveTextContent("OpenAiSpendChartTitle"); + expect(JSON.parse(window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY) ?? "{}")).toEqual({ + openaiapi: "spend", + }); + window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY); + }); + it("masks the Admin project id in the card identity when privacy is enabled", async () => { const provider = snapshot("openaiapi", usage()); provider.planName = "Admin API: proj-private"; diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx index a24c640c0a6..9bdebbe7715 100644 --- a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx @@ -4,6 +4,7 @@ import type { OpenAiApiDailyUsageSnapshot, OpenAiApiUsageSnapshot, } from "../types/bridge"; +import { dayLabel, formatUsd } from "../lib/openaiSpend"; import { BarChart } from "./charts/BarChart"; import { providerCostColor } from "./charts/chartPalette"; @@ -19,22 +20,12 @@ type T = (key: LocaleKey) => string; const METRICS: readonly Metric[] = ["cost", "tokens"]; const MAX_DETAIL_ROWS = 5; -const usdFormat = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); const compactFormat = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1, }); const countFormat = new Intl.NumberFormat("en-US"); -function formatUsd(value: number): string { - if (value > 0 && value < 0.01) return "<$0.01"; - return usdFormat.format(value); -} - -function dayLabel(day: OpenAiApiDailyUsageSnapshot): string { - return new Date(day.startTime * 1000).toISOString().slice(0, 10); -} - function metricValue(day: OpenAiApiDailyUsageSnapshot, metric: Metric): number { return metric === "cost" ? day.costUsd : day.totalTokens; } diff --git a/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx new file mode 100644 index 00000000000..013e05fbdd0 --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx @@ -0,0 +1,111 @@ +import { useId, useState } from "react"; +import type { LocaleKey } from "../i18n/keys"; +import { + hasSpendDays, + type ProviderChartMode, + readChartMode, + spendDaySummary, + storeChartMode, +} from "../lib/openaiSpend"; +import type { OpenAiApiUsageSnapshot, ProviderThroughputSnapshot } from "../types/bridge"; +import { ScrubBarStrip } from "./charts/ScrubBarStrip"; +import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; + +/** + * OpenAI API card chart with the upstream 0.75.0 Live · Spend switch + * (`ProviderMenuChartContent`). The switch only appears when there is both a + * Tokens/min window and daily spend to show; otherwise the card keeps the plain + * Tokens/min grid. Both layers stay mounted in one grid cell so switching never + * changes the card height, and the choice persists per provider. + */ + +type T = (key: LocaleKey) => string; + +const MODES: readonly { id: ProviderChartMode; label: LocaleKey }[] = [ + { id: "live", label: "OpenAiChartModeLive" }, + { id: "spend", label: "OpenAiChartModeSpend" }, +]; + +export interface OpenAILiveSpendChartProps { + providerId: string; + throughput: ProviderThroughputSnapshot; + usage: OpenAiApiUsageSnapshot | null | undefined; + t: T; +} + +export function OpenAILiveSpendChart({ providerId, throughput, usage, t }: OpenAILiveSpendChartProps) { + const [mode, setModeState] = useState(() => readChartMode(providerId)); + const spendUsage = usage && hasSpendDays(usage) ? usage : null; + if (!spendUsage) return ; + + const setMode = (next: ProviderChartMode) => { + setModeState(next); + storeChartMode(providerId, next); + }; + + return ( +
+
+ {MODES.map((option) => ( + + ))} +
+
+
+ +
+
+ +
+
+
+ ); +} + +function OpenAISpendChart({ usage, t }: { usage: OpenAiApiUsageSnapshot; t: T }) { + const summaryId = useId(); + const [selectedId, setSelectedId] = useState(null); + const bars = usage.daily.map((day) => ({ id: String(day.startTime), value: day.costUsd })); + const selectedDay = usage.daily.find((day) => String(day.startTime) === selectedId) ?? null; + const title = t("OpenAiSpendChartTitle").replace("{}", String(usage.historyDays)); + + return ( +
+
{title}
+ +

+ {selectedDay ? spendDaySummary(selectedDay, t) : "\u00a0"} +

+
+ ); +} diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx index babc247e10e..8f1c5b45aef 100644 --- a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx @@ -1,7 +1,7 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import type { LocaleKey } from "../i18n/keys"; -import { formatTokens } from "../lib/openaiThroughput"; +import { formatClock, formatTokens } from "../lib/openaiThroughput"; import type { ModelRateLimitSnapshot, ProviderThroughputSnapshot, @@ -12,6 +12,11 @@ import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; const START = 1_788_220_800; const t = (key: LocaleKey) => key as string; +/** jsdom has no PointerEvent, so fireEvent.pointer* drops clientX; React only reads the fields. */ +function pointer(target: Element, type: string, clientX: number) { + fireEvent(target, new MouseEvent(type, { bubbles: true, clientX })); +} + function model(name: string, overrides: Partial = {}): ThroughputModelSnapshot { return { name, @@ -96,6 +101,72 @@ describe("OpenAITokensPerMinuteChart", () => { expect(unmatched.container.querySelector(".openai-tpm__limit")).toBeNull(); }); + it("inspects the minute under the pointer and clears on leave", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + chart.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 56, right: 100, bottom: 56, x: 0, y: 0 }) as DOMRect; + const summary = () => container.querySelector(".openai-tpm__summary")?.textContent; + + pointer(chart, "pointermove", 30); + expect(summary()).toBe( + `${formatClock(START + 60)} · OpenAiTpmHoverMinute`, + ); + expect(container.querySelectorAll("rect.openai-tpm__bar--selected")).toHaveLength(1); + + pointer(chart, "pointermove", 95); + expect(summary()).toBe(`${formatClock(START + 240)} · OpenAiTpmReporting`); + + fireEvent.pointerLeave(chart); + expect(summary()).toBe("\u00a0"); + expect(container.querySelector("rect.openai-tpm__bar--selected")).toBeNull(); + }); + + it("keeps a drag selection when the pointer leaves the strip", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + chart.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 56, right: 100, bottom: 56, x: 0, y: 0 }) as DOMRect; + + pointer(chart, "pointerdown", 5); + pointer(chart, "pointermove", 150); + fireEvent.pointerLeave(chart); + expect(container.querySelector(".openai-tpm__summary")?.textContent).toBe( + `${formatClock(START + 240)} · OpenAiTpmReporting`, + ); + }); + + it("steps through minutes with the arrow keys", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + const summary = () => container.querySelector(".openai-tpm__summary")?.textContent; + fireEvent.keyDown(chart, { key: "ArrowRight" }); + expect(summary()).toBe(`${formatClock(START)} · OpenAiTpmHoverMinute`); + fireEvent.keyDown(chart, { key: "End" }); + expect(summary()).toBe(`${formatClock(START + 240)} · OpenAiTpmReporting`); + fireEvent.keyDown(chart, { key: "Escape" }); + expect(summary()).toBe("\u00a0"); + }); + + it("drops the selection when its minute leaves the window", () => { + const { container, getByTestId, rerender } = render( + , + ); + fireEvent.keyDown(getByTestId("openai-tpm-chart"), { key: "Home" }); + expect(container.querySelector("rect.openai-tpm__bar--selected")).not.toBeNull(); + const shifted = throughput(); + shifted.minutes = shifted.minutes.slice(1); + rerender(); + expect(container.querySelector("rect.openai-tpm__bar--selected")).toBeNull(); + expect(container.querySelector(".openai-tpm__summary")?.textContent).toBe("\u00a0"); + }); + it("lists per-model rows only when showModels is set", () => { const busy = throughput({ models: [model("gpt-5", { latestTpm: 900, peakTpm: 4_000 })] }); const card = render(); diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx index 0c04777d523..ce6bcad04bc 100644 --- a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx @@ -1,11 +1,14 @@ +import { useId, useState } from "react"; import type { LocaleKey } from "../i18n/keys"; import { formatClock, formatTokens, hasReportedTraffic, throughputLimitLine, + tpmBarSummary, tpmBars, } from "../lib/openaiThroughput"; +import { ScrubBarStrip } from "./charts/ScrubBarStrip"; import type { ModelRateLimitSnapshot, ProviderThroughputSnapshot, @@ -14,7 +17,9 @@ import type { /** * Last-hour Tokens/min grid for the OpenAI API (upstream 0.75.0 #4495). Reported * minutes are solid bars; minutes after the reporting frontier are drawn as - * pending outlines and never count as zero or as a peak. + * pending outlines and never count as zero or as a peak. Hovering, dragging or + * arrowing across the grid inspects one minute in the summary line, which keeps + * its height while empty so the card does not jump. */ type T = (key: LocaleKey) => string; @@ -36,9 +41,16 @@ export function OpenAITokensPerMinuteChart({ limits = NO_LIMITS, showModels = false, }: OpenAITokensPerMinuteChartProps) { + const summaryId = useId(); + const [selectedId, setSelectedId] = useState(null); const bars = tpmBars(throughput); + const stripBars = bars.map((bar) => ({ + id: String(bar.start), + value: bar.tokens, + isPending: bar.isPending, + })); + const selectedBar = bars.find((bar) => String(bar.start) === selectedId) ?? null; const reported = hasReportedTraffic(throughput); - const maxTokens = Math.max(1, ...bars.filter((bar) => !bar.isPending).map((bar) => bar.tokens)); const hasPending = bars.some((bar) => bar.isPending); const limit = throughputLimitLine(throughput, limits); @@ -76,36 +88,21 @@ export function OpenAITokensPerMinuteChart({ {reported && bars.length > 0 ? ( - - {bars.map((bar, index) => - bar.isPending ? ( - - ) : ( - - ), - )} - + <> + +

+ {selectedBar ? tpmBarSummary(selectedBar, t) : "\u00a0"} +

+ ) : (

{t("OpenAiTpmEmpty")}

)} diff --git a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx new file mode 100644 index 00000000000..4ed39a2abad --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx @@ -0,0 +1,143 @@ +import { type KeyboardEvent, type PointerEvent, useEffect, useRef } from "react"; + +/** + * Bar strip the pointer can hover or drag across to inspect one bar (upstream + * 0.75.0 `MenuUsageBarStrip`). The parent owns the selection so it can swap + * its summary line for the selected bar's summary; ids keep the selection on + * the same bar across refreshes and drop it when that bar leaves the window. + */ + +export interface ScrubBar { + id: string; + value: number; + /** Not reported yet: a full-height outline that never sets the scale. */ + isPending?: boolean; +} + +export interface ScrubBarStripProps { + bars: readonly ScrubBar[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + /** BEM block for `__bar`, `__bar--pending` and `__bar--selected`. */ + classPrefix: string; + className: string; + label: string; + /** Id of the summary line that reads out the selected bar. */ + describedBy?: string; + testId?: string; +} + +export function ScrubBarStrip({ + bars, + selectedId, + onSelect, + classPrefix, + className, + label, + describedBy, + testId, +}: ScrubBarStripProps) { + const dragging = useRef(false); + const max = Math.max(1, ...bars.filter((bar) => !bar.isPending).map((bar) => bar.value)); + const selectedIndex = bars.findIndex((bar) => bar.id === selectedId); + + useEffect(() => { + if (selectedId !== null && selectedIndex < 0) onSelect(null); + }, [selectedId, selectedIndex, onSelect]); + + const indexAt = (event: PointerEvent): number | null => { + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width <= 0 || bars.length === 0) return null; + const ratio = (event.clientX - rect.left) / rect.width; + if (!Number.isFinite(ratio)) return null; + return Math.min(bars.length - 1, Math.max(0, Math.floor(ratio * bars.length))); + }; + + const selectAt = (event: PointerEvent) => { + const index = indexAt(event); + if (index !== null) onSelect(bars[index].id); + }; + + const onPointerDown = (event: PointerEvent) => { + dragging.current = true; + // Capture keeps a drag scrubbing after the pointer slips off the strip. + event.currentTarget.setPointerCapture?.(event.pointerId); + selectAt(event); + }; + + const endDrag = (event: PointerEvent) => { + dragging.current = false; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const onPointerUp = (event: PointerEvent) => { + endDrag(event); + // Touch and pen have no hover to fall back to once the finger lifts. + if (event.pointerType !== "mouse") onSelect(null); + }; + + const onPointerLeave = () => { + if (!dragging.current) onSelect(null); + }; + + const onPointerCancel = (event: PointerEvent) => { + endDrag(event); + onSelect(null); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (bars.length === 0) return; + let next: number | null = null; + if (event.key === "ArrowLeft") next = selectedIndex < 0 ? bars.length - 1 : Math.max(0, selectedIndex - 1); + else if (event.key === "ArrowRight") next = selectedIndex < 0 ? 0 : Math.min(bars.length - 1, selectedIndex + 1); + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = bars.length - 1; + else if (event.key === "Escape" && selectedIndex >= 0) { + event.preventDefault(); + onSelect(null); + return; + } + if (next === null) return; + event.preventDefault(); + onSelect(bars[next].id); + }; + + return ( + onSelect(null)} + > + {bars.map((bar, index) => { + const classes = [`${classPrefix}__bar`]; + if (bar.isPending) classes.push(`${classPrefix}__bar--pending`); + if (index === selectedIndex) classes.push(`${classPrefix}__bar--selected`); + const height = bar.isPending ? 100 : (bar.value / max) * 100; + return ( + + ); + })} + + ); +} diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index af828fc8faa..b8c85d99160 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -907,6 +907,13 @@ export const CORE_LOCALE_KEYS = [ "OpenAiTpmEmpty", "OpenAiTpmModels", "OpenAiTpmLimitLine", + "OpenAiChartModeLabel", + "OpenAiChartModeLive", + "OpenAiChartModeSpend", + "OpenAiTpmHoverMinute", + "OpenAiTpmReporting", + "OpenAiSpendChartTitle", + "OpenAiSpendHoverDay", "OpenAiLimitsTitle", "OpenAiLimitsHelp", "OpenAiLimitModelLabel", diff --git a/apps/desktop-tauri/src/lib/openaiSpend.test.ts b/apps/desktop-tauri/src/lib/openaiSpend.test.ts new file mode 100644 index 00000000000..f81e5eb1357 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiSpend.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenAiApiDailyUsageSnapshot } from "../types/bridge"; +import { + formatUsd, + hasSpendDays, + PROVIDER_CHART_MODES_STORAGE_KEY, + readChartMode, + spendDaySummary, + storeChartMode, +} from "./openaiSpend"; + +const START = 1_788_220_800; + +function day(overrides: Partial = {}): OpenAiApiDailyUsageSnapshot { + return { + startTime: START, + endTime: START + 86_400, + costUsd: 12.5, + requests: 10, + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 400, + totalTokens: 1_400, + lineItems: [], + models: [], + ...overrides, + }; +} + +const t = (key: string) => (key === "OpenAiSpendHoverDay" ? "{}: {} · {} tokens" : key); + +describe("spend helpers", () => { + it("formats sub-cent spend without rounding it to zero", () => { + expect(formatUsd(0.004)).toBe("<$0.01"); + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(12.5)).toBe("$12.50"); + }); + + it("summarizes a UTC day as date, cost and tokens", () => { + expect(spendDaySummary(day(), t)).toBe("2026-09-01: $12.50 · 1.4K tokens"); + }); + + it("needs at least one day of history to offer Spend", () => { + expect(hasSpendDays(null)).toBe(false); + expect(hasSpendDays({ historyDays: 30, projectId: null, daily: [] })).toBe(false); + expect(hasSpendDays({ historyDays: 30, projectId: null, daily: [day()] })).toBe(true); + }); +}); + +describe("chart mode persistence", () => { + afterEach(() => window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY)); + + it("defaults to Live and remembers the choice per provider", () => { + expect(readChartMode("openaiapi")).toBe("live"); + storeChartMode("openaiapi", "spend"); + expect(readChartMode("openaiapi")).toBe("spend"); + expect(readChartMode("other")).toBe("live"); + storeChartMode("other", "live"); + expect(JSON.parse(window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY) ?? "")).toEqual({ + openaiapi: "spend", + other: "live", + }); + }); + + it("falls back to Live for unknown or malformed stored values", () => { + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, JSON.stringify({ openaiapi: "weekly" })); + expect(readChartMode("openaiapi")).toBe("live"); + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, "not json"); + expect(readChartMode("openaiapi")).toBe("live"); + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, "[]"); + expect(readChartMode("openaiapi")).toBe("live"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/openaiSpend.ts b/apps/desktop-tauri/src/lib/openaiSpend.ts new file mode 100644 index 00000000000..26e2b090dfd --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiSpend.ts @@ -0,0 +1,61 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { OpenAiApiDailyUsageSnapshot, OpenAiApiUsageSnapshot } from "../types/bridge"; +import { formatTokens } from "./openaiThroughput"; + +const usdFormat = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + +/** USD amount; sub-cent spend reads as "<$0.01" rather than "$0.00". */ +export function formatUsd(value: number): string { + if (value > 0 && value < 0.01) return "<$0.01"; + return usdFormat.format(value); +} + +/** The Admin API reports UTC days, so the label stays on the UTC calendar. */ +export function dayLabel(day: OpenAiApiDailyUsageSnapshot): string { + return new Date(day.startTime * 1000).toISOString().slice(0, 10); +} + +/** True when the OpenAI API usage history has at least one day to chart. */ +export function hasSpendDays(usage: OpenAiApiUsageSnapshot | null | undefined): boolean { + return (usage?.daily.length ?? 0) > 0; +} + +/** Hover line for one day, upstream `InlineUsageDashboardModel.HoverDetail.summary`. */ +export function spendDaySummary(day: OpenAiApiDailyUsageSnapshot, t: (key: LocaleKey) => string): string { + return t("OpenAiSpendHoverDay") + .replace("{}", dayLabel(day)) + .replace("{}", formatUsd(day.costUsd)) + .replace("{}", formatTokens(day.totalTokens)); +} + +export type ProviderChartMode = "live" | "spend"; + +export const PROVIDER_CHART_MODES_STORAGE_KEY = "codexbar.providerMenuChartModes"; + +function readModes(): Record { + try { + const raw = window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** The stored Live / Spend choice for a provider; anything unknown is Live. */ +export function readChartMode(providerId: string): ProviderChartMode { + return readModes()[providerId] === "spend" ? "spend" : "live"; +} + +export function storeChartMode(providerId: string, mode: ProviderChartMode): void { + try { + const modes = readModes(); + modes[providerId] = mode; + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, JSON.stringify(modes)); + } catch { + // Not persisting the mode is harmless; the card falls back to Live. + } +} diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.ts b/apps/desktop-tauri/src/lib/openaiThroughput.ts index 140c7c33605..f416079c9d7 100644 --- a/apps/desktop-tauri/src/lib/openaiThroughput.ts +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -21,6 +21,7 @@ export interface TpmBar { /** Minute start, epoch seconds. */ start: number; tokens: number; + requests: number; /** Not reported yet: drawn as pending, never as a zero or a peak. */ isPending: boolean; } @@ -30,10 +31,21 @@ export function tpmBars(snapshot: ProviderThroughputSnapshot): TpmBar[] { return snapshot.minutes.map((minute) => ({ start: minute.start, tokens: minute.totalTokens, + requests: minute.requests, isPending: minute.isPending, })); } +/** Hover line for one minute, upstream `ProviderThroughputSection.points`. */ +export function tpmBarSummary(bar: TpmBar, t: (key: LocaleKey) => string): string { + const time = formatClock(bar.start); + if (bar.isPending) return `${time} · ${t("OpenAiTpmReporting")}`; + const detail = t("OpenAiTpmHoverMinute") + .replace("{}", formatTokens(bar.tokens)) + .replace("{}", formatTokens(bar.requests)); + return `${time} · ${detail}`; +} + /** True when at least one reported (non-pending) minute carries tokens. */ export function hasReportedTraffic(snapshot: ProviderThroughputSnapshot): boolean { return snapshot.minutes.some((minute) => !minute.isPending && minute.totalTokens > 0); diff --git a/apps/desktop-tauri/src/proof/fixtures/common.ts b/apps/desktop-tauri/src/proof/fixtures/common.ts index 3cfeaa00c52..2ea6a6a0c31 100644 --- a/apps/desktop-tauri/src/proof/fixtures/common.ts +++ b/apps/desktop-tauri/src/proof/fixtures/common.ts @@ -90,6 +90,7 @@ export const PROOF_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ { id: "kiro", displayName: "Kiro", cookieDomain: null }, { id: "zai", displayName: "z.ai", cookieDomain: null }, { id: "mistral", displayName: "Mistral", cookieDomain: null }, + { id: "openaiapi", displayName: "OpenAI API", cookieDomain: null }, ]; export function catalogOrder(): string[] { diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 5316166a55d..e3bdbc63a73 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -21,6 +21,7 @@ export const PROOF_SCENARIO_NAMES = [ "two-codex-accounts", "errors", "empty", + "openai-live-spend", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -322,11 +323,94 @@ function emptyScenario(): ProofScenario { }; } +const MINUTE_SECONDS = 60; +const DAY_SECONDS = 86_400; + +/** + * OpenAI API card with both a Tokens/min window and daily spend, so the card + * offers the Live · Spend switch. The last two minutes are still reporting. + */ +function openAiApiSnapshot(now: Date): ProviderUsageSnapshot { + const nowSeconds = Math.floor(now.getTime() / 1000); + const firstMinute = Math.floor(nowSeconds / MINUTE_SECONDS) * MINUTE_SECONDS - 59 * MINUTE_SECONDS; + const minutes = Array.from({ length: 60 }, (_, i) => { + const isPending = i >= 58; + const totalTokens = isPending ? 0 : 6_000 + ((i * 7_919) % 18_000); + return { + start: firstMinute + i * MINUTE_SECONDS, + totalTokens, + requests: isPending ? 0 : 2 + (i % 5), + isPending, + }; + }); + const reported = minutes.filter((minute) => !minute.isPending); + const peak = reported.reduce((best, minute) => (minute.totalTokens > best.totalTokens ? minute : best)); + const lastFive = reported.slice(-5); + const firstDay = Math.floor(nowSeconds / DAY_SECONDS) * DAY_SECONDS - 29 * DAY_SECONDS; + const daily = Array.from({ length: 30 }, (_, i) => { + const costUsd = Math.round((1.5 + ((i * 37) % 11) * 0.45) * 100) / 100; + const totalTokens = Math.round(costUsd * 190_000); + return { + startTime: firstDay + i * DAY_SECONDS, + endTime: firstDay + (i + 1) * DAY_SECONDS, + costUsd, + requests: 120 + i, + inputTokens: Math.round(totalTokens * 0.7), + cachedInputTokens: Math.round(totalTokens * 0.1), + outputTokens: Math.round(totalTokens * 0.3), + totalTokens, + lineItems: [{ name: "Text tokens", costUsd }], + models: [], + }; + }); + return providerSnapshot( + { + providerId: "openaiapi", + displayName: "OpenAI API", + primary: rateWindow(now, 0, null, null), + primaryLabel: "Spend", + updatedAt: isoBefore(now, 1), + }, + { + planName: "Admin API", + sourceLabel: "api", + throughput: { + minutes, + frontier: reported[reported.length - 1].start, + latestTokensPerMinute: reported[reported.length - 1].totalTokens, + peakTokensPerMinute: peak.totalTokens, + peakAt: peak.start, + fiveMinuteAverageTokensPerMinute: Math.round( + lastFive.reduce((sum, minute) => sum + minute.totalTokens, 0) / lastFive.length, + ), + models: [], + windowMinutes: 60, + fetchedAt: nowSeconds, + projectId: null, + }, + openAiApiUsage: { historyDays: 30, projectId: null, daily }, + }, + ); +} + +function openAiLiveSpendScenario(now: Date): ProofScenario { + return { + name: "openai-live-spend", + settings: baseSettings({ enabledProviders: ["openaiapi"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [openAiApiSnapshot(now)], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + const BUILDERS: Record ProofScenario> = { default: defaultScenario, "two-codex-accounts": twoCodexAccountsScenario, errors: errorsScenario, empty: emptyScenario, + "openai-live-spend": openAiLiveSpendScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/apps/desktop-tauri/src/styles/provider-details.css b/apps/desktop-tauri/src/styles/provider-details.css index d25d5a4dcb3..3196bbcd2af 100644 --- a/apps/desktop-tauri/src/styles/provider-details.css +++ b/apps/desktop-tauri/src/styles/provider-details.css @@ -660,6 +660,66 @@ opacity: 0.6; } +.openai-tpm__chart { + cursor: crosshair; + touch-action: none; + outline: none; +} + +.openai-tpm__chart:focus-visible { + outline: 1px solid var(--provider-accent, #6aa9ff); + outline-offset: 2px; +} + +.openai-tpm__bar--selected { + stroke: var(--text-primary); + stroke-width: 1.5; + stroke-dasharray: none; + vector-effect: non-scaling-stroke; + opacity: 1; +} + +.openai-tpm__summary { + margin: 0; + min-height: 1.4em; + font-size: 11px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Live · Spend switch: both layers share one cell so the card keeps its height. */ +.openai-live-spend { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.openai-live-spend__switch { + align-self: flex-start; +} + +.openai-live-spend__stack { + display: grid; +} + +.openai-live-spend__layer { + grid-area: 1 / 1; + min-width: 0; +} + +.openai-live-spend__layer > .openai-tpm { + margin-top: 0; +} + +.openai-live-spend__layer.is-hidden { + visibility: hidden; + pointer-events: none; +} + .openai-tpm__legend { display: flex; align-items: center; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index f3d3a0eba9a..f16b2707208 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1190,6 +1190,13 @@ locale_keys! { OpenAiTpmEmpty, OpenAiTpmModels, OpenAiTpmLimitLine, + OpenAiChartModeLabel, + OpenAiChartModeLive, + OpenAiChartModeSpend, + OpenAiTpmHoverMinute, + OpenAiTpmReporting, + OpenAiSpendChartTitle, + OpenAiSpendHoverDay, OpenAiLimitsTitle, OpenAiLimitsHelp, OpenAiLimitModelLabel, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index add5fcbd67f..0ff1f4c60b2 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1701,6 +1701,13 @@ OpenAiTpmPendingLegend = Pending: not reported yet OpenAiTpmEmpty = No traffic reported in this window yet. OpenAiTpmModels = Models OpenAiTpmLimitLine = { "{}" } peaked at { "{}" }% of its { "{}" } tokens-per-minute limit +OpenAiChartModeLabel = Chart +OpenAiChartModeLive = Live +OpenAiChartModeSpend = Spend +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } requests +OpenAiTpmReporting = reporting… +OpenAiSpendChartTitle = Daily spend · Last { "{}" } days +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Model rate limits OpenAiLimitsHelp = Enter your organization's per-model limits from the OpenAI dashboard to see utilization. Leave requests per minute empty when the model has no request limit. OpenAiLimitModelLabel = Model ID diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index aa7ecf767f6..199e4e4bd69 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1698,6 +1698,13 @@ OpenAiTpmPendingLegend = Pendiente: aún no reportado OpenAiTpmEmpty = Aún no hay tráfico reportado en esta ventana. OpenAiTpmModels = Modelos OpenAiTpmLimitLine = El pico de { "{}" } usó el { "{}" }% de su límite de tokens por minuto ({ "{}" }) +OpenAiChartModeLabel = Gráfico +OpenAiChartModeLive = En vivo +OpenAiChartModeSpend = Gasto +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } solicitudes +OpenAiTpmReporting = informando… +OpenAiSpendChartTitle = Gasto diario · Últimos { "{}" } días +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Límites de tasa por modelo OpenAiLimitsHelp = Escribe los límites por modelo de tu organización desde el panel de OpenAI para ver el uso. Deja solicitudes por minuto vacío si el modelo no tiene límite de solicitudes. OpenAiLimitModelLabel = ID del modelo diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 93fac80518a..efa2065551b 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1699,6 +1699,13 @@ OpenAiTpmPendingLegend = 保留中:まだ報告されていません OpenAiTpmEmpty = この期間にはまだ報告されたトラフィックがありません。 OpenAiTpmModels = モデル OpenAiTpmLimitLine = { "{}" } のピークは上限の { "{}" }%(毎分 { "{}" } トークン) +OpenAiChartModeLabel = グラフ +OpenAiChartModeLive = ライブ +OpenAiChartModeSpend = 支出 +OpenAiTpmHoverMinute = { "{}" } トークン · { "{}" } リクエスト +OpenAiTpmReporting = 集計中… +OpenAiSpendChartTitle = 日別支出 · 過去 { "{}" } 日間 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } トークン OpenAiLimitsTitle = モデル別レート制限 OpenAiLimitsHelp = OpenAI ダッシュボードで確認した組織のモデル別上限を入力すると、使用率が表示されます。モデルにリクエスト数の上限がない場合、毎分リクエスト数は空欄のままにしてください。 OpenAiLimitModelLabel = モデル ID diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index b7c5f9b7264..923529fb02b 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1698,6 +1698,13 @@ OpenAiTpmPendingLegend = 대기 중: 아직 보고되지 않음 OpenAiTpmEmpty = 이 기간에 보고된 트래픽이 아직 없습니다. OpenAiTpmModels = 모델 OpenAiTpmLimitLine = { "{}" }의 최고치가 분당 토큰 한도의 { "{}" }%입니다(한도 { "{}" }) +OpenAiChartModeLabel = 차트 +OpenAiChartModeLive = 실시간 +OpenAiChartModeSpend = 지출 +OpenAiTpmHoverMinute = 토큰 { "{}" }개 · 요청 { "{}" }건 +OpenAiTpmReporting = 집계 중… +OpenAiSpendChartTitle = 일별 지출 · 최근 { "{}" }일 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · 토큰 { "{}" }개 OpenAiLimitsTitle = 모델별 속도 제한 OpenAiLimitsHelp = OpenAI 대시보드에서 확인한 조직의 모델별 한도를 입력하면 사용률을 볼 수 있습니다. 모델에 요청 한도가 없으면 분당 요청 수를 비워 두세요. OpenAiLimitModelLabel = 모델 ID diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index a9637e0fef3..935bfbb735d 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1703,6 +1703,13 @@ OpenAiTpmPendingLegend = Pendente: ainda não informado OpenAiTpmEmpty = Nenhum tráfego informado nesta janela ainda. OpenAiTpmModels = Modelos OpenAiTpmLimitLine = O pico de { "{}" } usou { "{}" }% do limite de tokens por minuto ({ "{}" }) +OpenAiChartModeLabel = Gráfico +OpenAiChartModeLive = Ao vivo +OpenAiChartModeSpend = Gastos +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } solicitações +OpenAiTpmReporting = informando… +OpenAiSpendChartTitle = Gasto diário · Últimos { "{}" } dias +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Limites de taxa por modelo OpenAiLimitsHelp = Informe os limites por modelo da sua organização no painel da OpenAI para ver a utilização. Deixe requisições por minuto vazio quando o modelo não tiver limite de requisições. OpenAiLimitModelLabel = ID do modelo diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 2eb20cc1c5a..14f82e849e1 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1699,6 +1699,13 @@ OpenAiTpmPendingLegend = Ожидается: данные ещё не получ OpenAiTpmEmpty = За этот период пока нет переданного трафика. OpenAiTpmModels = Модели OpenAiTpmLimitLine = Пик { "{}" } составил { "{}" }% от лимита токенов в минуту ({ "{}" }) +OpenAiChartModeLabel = График +OpenAiChartModeLive = Сейчас +OpenAiChartModeSpend = Расходы +OpenAiTpmHoverMinute = { "{}" } токенов · { "{}" } запросов +OpenAiTpmReporting = идёт отчёт… +OpenAiSpendChartTitle = Расходы по дням · Последние { "{}" } дн. +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } токенов OpenAiLimitsTitle = Лимиты скорости по моделям OpenAiLimitsHelp = Введите лимиты организации по моделям из панели OpenAI, чтобы видеть загрузку. Если у модели нет лимита запросов, оставьте поле запросов в минуту пустым. OpenAiLimitModelLabel = ID модели diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index e4a6e6563db..d0732829984 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1699,6 +1699,13 @@ OpenAiTpmPendingLegend = Bekliyor: henüz bildirilmedi OpenAiTpmEmpty = Bu pencerede henüz bildirilmiş trafik yok. OpenAiTpmModels = Modeller OpenAiTpmLimitLine = { "{}" } zirvesi, dakikadaki token limitinin %{ "{}" }'ine ulaştı (limit { "{}" }) +OpenAiChartModeLabel = Grafik +OpenAiChartModeLive = Canlı +OpenAiChartModeSpend = Harcama +OpenAiTpmHoverMinute = { "{}" } token · { "{}" } istek +OpenAiTpmReporting = raporlanıyor… +OpenAiSpendChartTitle = Günlük harcama · Son { "{}" } gün +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } token OpenAiLimitsTitle = Modele göre hız limitleri OpenAiLimitsHelp = Kuruluşunuzun modele göre limitlerini OpenAI panelinden girin; kullanım oranını görürsünüz. Modelde istek limiti yoksa dakikadaki istek alanını boş bırakın. OpenAiLimitModelLabel = Model kimliği diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index fe58bdebdc3..05110fa8460 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1701,6 +1701,13 @@ OpenAiTpmPendingLegend = Очікується: дані ще не отриман OpenAiTpmEmpty = За цей період ще немає переданого трафіку. OpenAiTpmModels = Моделі OpenAiTpmLimitLine = Пік { "{}" } становить { "{}" }% ліміту токенів за хвилину ({ "{}" }) +OpenAiChartModeLabel = Графік +OpenAiChartModeLive = Зараз +OpenAiChartModeSpend = Витрати +OpenAiTpmHoverMinute = { "{}" } токенів · { "{}" } запитів +OpenAiTpmReporting = надходять дані… +OpenAiSpendChartTitle = Витрати за днями · Останні { "{}" } дн. +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } токенів OpenAiLimitsTitle = Ліміти швидкості за моделями OpenAiLimitsHelp = Введіть ліміти організації за моделями з панелі OpenAI, щоб бачити завантаження. Якщо для моделі немає ліміту запитів, залиште поле запитів за хвилину порожнім. OpenAiLimitModelLabel = ID моделі diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 41aab4c3aae..d54d22e1097 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1699,6 +1699,13 @@ OpenAiTpmPendingLegend = 待定:尚未报告 OpenAiTpmEmpty = 此时间窗口内尚无已报告的流量。 OpenAiTpmModels = 模型 OpenAiTpmLimitLine = { "{}" } 的峰值达到其每分钟 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiChartModeLabel = 图表 +OpenAiChartModeLive = 实时 +OpenAiChartModeSpend = 花费 +OpenAiTpmHoverMinute = { "{}" } 个 Token · { "{}" } 次请求 +OpenAiTpmReporting = 正在上报… +OpenAiSpendChartTitle = 每日花费 · 最近 { "{}" } 天 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } 个 Token OpenAiLimitsTitle = 模型速率限制 OpenAiLimitsHelp = 请填写 OpenAI 控制台中组织的各模型限额以查看使用率。若模型没有请求数限制,请将每分钟请求数留空。 OpenAiLimitModelLabel = 模型 ID diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index cc9b995f27f..290279e55ee 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1699,6 +1699,13 @@ OpenAiTpmPendingLegend = 待定:尚未回報 OpenAiTpmEmpty = 此時間範圍內尚無已回報的流量。 OpenAiTpmModels = 模型 OpenAiTpmLimitLine = { "{}" } 的高峰達到其每分鐘 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiChartModeLabel = 圖表 +OpenAiChartModeLive = 即時 +OpenAiChartModeSpend = 花費 +OpenAiTpmHoverMinute = { "{}" } 個 Token · { "{}" } 次請求 +OpenAiTpmReporting = 正在回報… +OpenAiSpendChartTitle = 每日花費 · 最近 { "{}" } 天 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } 個 Token OpenAiLimitsTitle = 模型速率限制 OpenAiLimitsHelp = 請填入 OpenAI 後台中組織的各模型限額以查看使用率。若模型沒有請求數限制,請將每分鐘請求數留空。 OpenAiLimitModelLabel = 模型 ID From edaa55a608e28216e2690ba86944f2c00a167948 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Tue, 20 Oct 2026 07:39:31 +0700 Subject: [PATCH 2/3] Keep Escape from closing the tray panel while a bar is selected --- apps/desktop-tauri/scripts/proof-check.mjs | 32 ++++++++++ .../scripts/proof-scenarios.json | 3 + .../components/charts/ScrubBarStrip.test.tsx | 58 +++++++++++++++++++ .../src/components/charts/ScrubBarStrip.tsx | 3 + 4 files changed, 96 insertions(+) create mode 100644 apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index 4462d4fbd5f..2b4eb9e5efd 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -12,6 +12,20 @@ // pnpm proof:check -- --browser browser binary (or env PROOF_BROWSER) // pnpm proof:check -- --out screenshot folder (default .proof/screens) // +// Scenario steps (scripts/proof-scenarios.json), run in order: +// expectText / expectNoText page text includes / excludes each string +// expectSelector a DOM selector matches +// expectTheme documentElement dataset.theme +// expectField a native by label +// screenshot PNG into --out (clip "panel" crops to the tray height) +// wait sleep N ms +// // Exit codes: 0 all scenarios passed, 1 a scenario failed, 2 setup failed. import { spawn } from "node:child_process"; @@ -317,6 +331,8 @@ class Page { } const js = (value) => JSON.stringify(value); +// Windows virtual-key codes for the keys a `key` step may press. +const KEY_CODES = { Escape: 27, End: 35, Home: 36, ArrowLeft: 37, ArrowRight: 39 }; const asList = (value) => (Array.isArray(value) ? value : [value]); // textContent rather than innerText: the app splits emails across elements, @@ -417,6 +433,22 @@ async function runStep(page, step, ctx) { return ""; })()`; if (!(await page.poll(`${mismatch} === ""`))) throw new Error(await page.eval(mismatch)); + } else if (step.expectNoCall !== undefined) { + // A call that would have been made lands during the same event turn; the + // delay gives an asynchronous one a chance to be recorded before checking. + await sleep(300); + const { cmd } = step.expectNoCall; + const made = await page.eval( + `(window.__CODEXBAR_PROOF__?.calls() ?? []).some((call) => call.cmd === ${js(cmd)})`, + ); + if (made) throw new Error(`unexpected backend call: ${cmd}`); + } else if (step.key !== undefined) { + // Keys go to the focused element, as a real key press would. + const info = KEY_CODES[step.key]; + if (!info) throw new Error(`unsupported key ${js(step.key)}`); + const params = { key: step.key, code: step.key, windowsVirtualKeyCode: info, nativeVirtualKeyCode: info }; + await page.send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); } else if (step.wait !== undefined) { await sleep(step.wait); } else { diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index ed369116dcc..9bda88afff5 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -166,6 +166,9 @@ { "expectText": "requests" }, { "expectSelector": "[data-testid=\"openai-live-layer\"] rect.openai-tpm__bar--selected" }, { "screenshot": "tray-openai-live-inspect", "clip": "panel" }, + { "key": "Escape" }, + { "expectNoCall": { "cmd": "dismiss_tray_panel" } }, + { "expectSelector": ".tray-panel-reveal--ready" }, { "click": { "selector": "button", "label": "Spend" } }, { "expectText": "Daily spend · Last 30 days" }, { "expectSelector": ".openai-live-spend[data-mode=\"spend\"] [data-testid=\"openai-live-layer\"][aria-hidden=\"true\"]" }, diff --git a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx new file mode 100644 index 00000000000..442688c8f42 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx @@ -0,0 +1,58 @@ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ScrubBarStrip, type ScrubBar } from "./ScrubBarStrip"; + +const bars: ScrubBar[] = [ + { id: "a", value: 1 }, + { id: "b", value: 2 }, +]; + +function renderStrip(selectedId: string | null, onSelect: (id: string | null) => void) { + return render( + , + ); +} + +describe("ScrubBarStrip Escape key", () => { + afterEach(() => { + cleanup(); + }); + + it("clears the selection without letting Escape reach window listeners", () => { + const onSelect = vi.fn(); + const windowListener = vi.fn(); + window.addEventListener("keydown", windowListener); + try { + const { container } = renderStrip("a", onSelect); + fireEvent.keyDown(container.querySelector("svg")!, { key: "Escape" }); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(null); + expect(windowListener).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", windowListener); + } + }); + + it("lets Escape reach window listeners when no bar is selected", () => { + const onSelect = vi.fn(); + const windowListener = vi.fn(); + window.addEventListener("keydown", windowListener); + try { + const { container } = renderStrip(null, onSelect); + fireEvent.keyDown(container.querySelector("svg")!, { key: "Escape" }); + + expect(onSelect).not.toHaveBeenCalled(); + expect(windowListener).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener("keydown", windowListener); + } + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx index 1b683e9d1bd..27d810af187 100644 --- a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx +++ b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx @@ -112,6 +112,9 @@ export function ScrubBarStrip({ else if (event.key === "End") next = bars.length - 1; else if (event.key === "Escape" && selectedIndex >= 0) { event.preventDefault(); + // Only clear the inspected bar; with nothing selected Escape still + // reaches the window listener that dismisses the tray panel. + event.stopPropagation(); onSelect(null); return; } From 04f32b04bac9f6308f419ab017f28e6542d83ddd Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Tue, 20 Oct 2026 07:39:31 +0700 Subject: [PATCH 3/3] Gate the OpenAI Live/Spend switch on reported traffic --- apps/desktop-tauri/src/components/MenuCard.tsx | 1 + .../src/components/MenuCardOpenAIUsage.test.tsx | 15 +++++++++++++++ .../src/components/OpenAILiveSpendChart.tsx | 5 ++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index fa5b940a5d2..41a16c95bef 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -300,6 +300,7 @@ export default function MenuCard({ {presence.wayfinderUsage && } {presence.openAiThroughput && ( { window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY); }); + it("keeps the plain grid when the Tokens/min window has no reported traffic", async () => { + window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY); + const provider = withThroughput(snapshot("openaiapi", usage())); + const throughput = provider.throughput!; + provider.throughput = { + ...throughput, + minutes: throughput.minutes.map((minute) => ({ ...minute, isPending: true })), + }; + const { container } = renderCard(provider); + await waitFor(() => expect(container.querySelector(".openai-tpm")).not.toBeNull()); + expect(container.querySelector(".openai-tpm__empty")).not.toBeNull(); + expect(container.querySelector(".openai-live-spend")).toBeNull(); + expect(container.querySelector(".openai-live-spend__switch")).toBeNull(); + }); + it("masks the Admin project id in the card identity when privacy is enabled", async () => { const provider = snapshot("openaiapi", usage()); provider.planName = "Admin API: proj-private"; diff --git a/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx index 0cc02b08bd0..d1020d2a87b 100644 --- a/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx +++ b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx @@ -1,5 +1,6 @@ import { useId, useState } from "react"; import type { LocaleKey } from "../i18n/keys"; +import { hasReportedTraffic } from "../lib/openaiThroughput"; import { hasSpendDays, type ProviderChartMode, @@ -35,7 +36,9 @@ export interface OpenAILiveSpendChartProps { export function OpenAILiveSpendChart({ providerId, throughput, usage, t }: OpenAILiveSpendChartProps) { const [mode, setModeState] = useState(() => readChartMode(providerId)); - const spendUsage = usage && hasSpendDays(usage) ? usage : null; + // The switch needs both charts to have something to show: reported Tokens/min + // traffic and daily spend. Otherwise the card keeps the plain grid. + const spendUsage = usage && hasSpendDays(usage) && hasReportedTraffic(throughput) ? usage : null; if (!spendUsage) return ; const setMode = (next: ProviderChartMode) => {