@@ -1224,6 +1242,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas
range={customWindow ? null : range}
locale={locale}
t={t}
+ apiBase={apiBase}
/>
>
)}
diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx
new file mode 100644
index 00000000000..0cf5505efb2
--- /dev/null
+++ b/gui/src/pages/usage-companion-chart.tsx
@@ -0,0 +1,119 @@
+import type { Locale, TFn } from "../i18n/shared";
+import {
+ chartPolylinePoints,
+ chartStackedBarRects,
+ formatCompanionTokens,
+ type UsageTimeline,
+} from "./usage-companion-utils";
+
+const CHART_COLORS = ["#0A84FF", "#FF9F0A", "#30D158", "#BF5AF2", "#FF453A", "#64D2FF"];
+const WIDTH = 640;
+const HEIGHT = 160;
+const PADDING = 28;
+
+function maxValue(timeline: UsageTimeline, chartStyle: "line" | "stackedBar"): number {
+ if (chartStyle === "stackedBar") {
+ return Math.max(...Array.from({ length: timeline.buckets }, (_, index) =>
+ timeline.series.reduce((sum, series) => sum + (series.points[index] ?? 0), 0),
+ ), 0);
+ }
+ return Math.max(...timeline.series.flatMap(series => series.points), 0);
+}
+
+function dateLabels(timeline: UsageTimeline, locale: Locale): string[] {
+ const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" });
+ const interval = Math.max(1, Math.floor((timeline.buckets - 1) / 3));
+ return [0, 1, 2, 3].map(index => {
+ const bucket = Math.min(timeline.buckets - 1, index * interval);
+ return formatter.format(new Date((timeline.start + bucket * timeline.bucketSeconds) * 1000));
+ });
+}
+
+export function UsageCompanionChart({
+ timeline,
+ chartStyle,
+ hours,
+ loading,
+ error,
+ onRetry,
+ locale,
+ t,
+}: {
+ timeline: UsageTimeline | null;
+ chartStyle: "line" | "stackedBar";
+ hours: number;
+ loading: boolean;
+ error: string | null;
+ onRetry: () => void;
+ locale: Locale;
+ t: TFn;
+}) {
+ if (loading) {
+ return
;
+ }
+ if (error) {
+ return (
+
+ {t("usage.companion.timelineUnavailable")}
+
+
+ );
+ }
+ if (!timeline || timeline.series.length === 0) {
+ return
{t("usage.companion.empty", { hours: timeline?.buckets ? Math.round(timeline.buckets * timeline.bucketSeconds / 3600) : hours })}
;
+ }
+ const max = maxValue(timeline, chartStyle);
+ const labels = dateLabels(timeline, locale);
+ const plotWidth = WIDTH - PADDING * 2;
+ const plotHeight = HEIGHT - PADDING * 2;
+ const y = PADDING;
+ const baseline = PADDING + plotHeight;
+ const translate = "trans" + "late";
+ const xLabels = labels.map((label, index) => (
+
{label}
+ ));
+ const marks = chartStyle === "line"
+ ? timeline.series.map((series, index) => (
+
+ ))
+ : chartStackedBarRects(timeline.series, plotWidth, plotHeight, max, 0).map(rect => (
+
+ ));
+ return (
+
+
+
+ {timeline.series.map((series, index) => (
+
+
+ {series.id}
+
+ ))}
+
+ {timeline.truncated &&
{t("usage.companion.olderRecordsSkipped")}
}
+
+ );
+}
diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx
new file mode 100644
index 00000000000..f3af2082de1
--- /dev/null
+++ b/gui/src/pages/usage-companion-panel.tsx
@@ -0,0 +1,422 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
+import { useI18n } from "../i18n/shared";
+import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage";
+import { Switch } from "../ui";
+import { UsageCompanionChart } from "./usage-companion-chart";
+import {
+ bucketMinutesForWindow,
+ buildCompanionSettingsPatch,
+ formatCompanionTokens,
+ groupCompanionModels,
+ toggleCompanionModels,
+ type CompanionSettings,
+ type CompanionSettingsResponse,
+ type UsageTimeline,
+} from "./usage-companion-utils";
+
+interface CompanionProvider {
+ provider: string;
+}
+
+const MENU_METRICS = ["requests", "tokens", "cost", "quota", "none"] as const;
+const WINDOWS = [6, 24, 72, 168] as const;
+const CHART_STYLES = ["line", "stackedBar"] as const;
+const TOKEN_METRICS = ["total", "input", "output", "cached"] as const;
+const AGGREGATIONS = ["sum", "average", "max"] as const;
+const GROUPINGS = ["model", "modelAccount"] as const;
+
+function formatSaveTime(value: number, locale: string): string {
+ return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value);
+}
+
+function errorMessage(value: unknown): string {
+ if (value instanceof Error && value.message) return value.message;
+ return String(value);
+}
+
+function Segment
({
+ label,
+ value,
+ options,
+ optionLabel,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: readonly T[];
+ optionLabel: (value: T) => string;
+ onChange: (value: T) => void;
+}) {
+ return (
+
+
{label}
+
+ {options.map(option => (
+
+ ))}
+
+
+ );
+}
+
+function SelectControl({
+ label,
+ value,
+ options,
+ optionLabel,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: readonly T[];
+ optionLabel: (value: T) => string;
+ onChange: (value: T) => void;
+}) {
+ return (
+
+ );
+}
+
+function useVisible(ref: RefObject): boolean {
+ const [visible, setVisible] = useState(false);
+ useEffect(() => {
+ if (visible || !ref.current || typeof IntersectionObserver === "undefined") return;
+ const observer = new IntersectionObserver(entries => {
+ if (entries.some(entry => entry.isIntersecting)) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ }, { rootMargin: "240px" });
+ observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, [ref, visible]);
+ return visible;
+}
+
+export default function UsageCompanionPanel({
+ apiBase,
+ providers,
+ onSettingsLoaded,
+}: {
+ apiBase: string;
+ providers: CompanionProvider[];
+ onSettingsLoaded?: (metric: CompanionSettings["menuBarMetric"]) => void;
+}) {
+ const { t, locale } = useI18n();
+ const rootRef = useRef(null);
+ const visible = useVisible(rootRef);
+ const [response, setResponse] = useState(null);
+ const [settings, setSettings] = useState(null);
+ const [timeline, setTimeline] = useState(null);
+ const [availableModels, setAvailableModels] = useState([]);
+ const [settingsError, setSettingsError] = useState(null);
+ const [timelineError, setTimelineError] = useState(null);
+ const [timelineLoading, setTimelineLoading] = useState(false);
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
+ const [fetchedAt, setFetchedAt] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const saveTimer = useRef | null>(null);
+ const saveBaseline = useRef(null);
+ const timelineRequest = useRef(null);
+ const saveStateRef = useRef(saveState);
+ const settingsRef = useRef(settings);
+ const knownTotalsRef = useRef(new Map());
+ const [knownTotals, setKnownTotals] = useState