diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx
index 3c3e42960..dc232a1a3 100644
--- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx
+++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx
@@ -35,17 +35,79 @@ function thresholdColor(percent: number): string {
return "#22C55E";
}
-function weeklyPercentFor(snapshot: UsageSnapshot | null, provider: UsageProvider): number | null {
+function clampPercent(percent: number): number | null {
+ if (!Number.isFinite(percent)) return null;
+ return Math.max(0, Math.min(100, percent));
+}
+
+function windowPercentFor(
+ snapshot: UsageSnapshot | null,
+ provider: UsageProvider,
+ windowType: "five_hour" | "weekly" | "monthly",
+): number | null {
if (!snapshot) return null;
- const weekly = snapshot.windows.find(
- (w) => w.provider === provider && (w.windowType === "weekly" || w.windowType === "monthly"),
- );
- if (!weekly) return null;
- return Math.max(0, Math.min(100, weekly.percentUsed));
+ const window = snapshot.windows.find((w) => w.provider === provider && w.windowType === windowType);
+ if (!window) return null;
+ return clampPercent(window.percentUsed);
}
const OPEN_USAGE_EVENT = "ade-open-usage-drawer";
-const HEADER_USAGE_STARTUP_DELAY_MS = 5_000;
+const HEADER_USAGE_REFRESH_INTERVAL_MS = 120_000;
+const HEADER_USAGE_FOCUS_REFRESH_STALE_MS = 60_000;
+const HEADER_USAGE_PROVIDER_STATUS_REFRESH_MS = 300_000;
+
+type HeaderUsageWindowSummary = {
+ fiveHourPercent: number | null;
+ planPercent: number | null;
+ planLabel: "wk" | "mo";
+};
+
+function headerUsageFor(snapshot: UsageSnapshot | null, provider: UsageProvider): HeaderUsageWindowSummary {
+ const weeklyPercent = windowPercentFor(snapshot, provider, "weekly");
+ const monthlyPercent = windowPercentFor(snapshot, provider, "monthly");
+ return {
+ fiveHourPercent: windowPercentFor(snapshot, provider, "five_hour"),
+ planPercent: weeklyPercent ?? monthlyPercent,
+ planLabel: weeklyPercent == null && monthlyPercent != null ? "mo" : "wk",
+ };
+}
+
+function percentLabel(percent: number | null): string {
+ return percent == null ? "…" : `${Math.round(percent)}%`;
+}
+
+function percentStyle(percent: number | null): React.CSSProperties {
+ return {
+ color: percent == null ? "var(--color-muted-fg)" : thresholdColor(percent),
+ };
+}
+
+function formatUsageTitle(usage: HeaderUsageWindowSummary): string {
+ return `5h ${percentLabel(usage.fiveHourPercent)}, ${usage.planLabel} ${percentLabel(usage.planPercent)}`;
+}
+
+function HeaderProviderUsageChip({
+ provider,
+ usage,
+}: {
+ provider: UsageProvider;
+ usage: HeaderUsageWindowSummary;
+}) {
+ return (
+
+
+
+ 5h
+ {percentLabel(usage.fiveHourPercent)}
+ {usage.planLabel}
+ {percentLabel(usage.planPercent)}
+
+
+ );
+}
export function HeaderUsageControl() {
const [open, setOpen] = useState(false);
@@ -56,51 +118,92 @@ export function HeaderUsageControl() {
const [budgetError, setBudgetError] = useState(null);
const [guardrailsOpen, setGuardrailsOpen] = useState(false);
const panelRef = useRef(null);
+ const snapshotRef = useRef(null);
+
+ const applySnapshot = useCallback((nextSnapshot: UsageSnapshot | null) => {
+ snapshotRef.current = nextSnapshot;
+ setSnapshot(nextSnapshot);
+ }, []);
- // Delay background usage reads during project boot, but hydrate immediately
- // when the drawer is opened.
useEffect(() => {
if (!window.ade?.usage) return;
+ const usageBridge = window.ade.usage;
let cancelled = false;
- let unsubscribe: (() => void) | undefined;
- const timer = window.setTimeout(() => {
- window.ade.usage.getSnapshot()
- .then((nextSnapshot) => {
- if (!cancelled) setSnapshot(nextSnapshot);
- })
- .catch(() => {
- if (!cancelled) setSnapshot(null);
- });
- unsubscribe = window.ade.usage.onUpdate?.((next) => {
- if (!cancelled) setSnapshot(next);
+ let requestSerial = 0;
+ const readSnapshot = async (force: boolean) => {
+ try {
+ const nextSnapshot = force
+ ? await usageBridge.refresh()
+ : await usageBridge.getSnapshot();
+ return { failed: false, snapshot: nextSnapshot };
+ } catch {
+ return { failed: true, snapshot: null };
+ }
+ };
+ const unsubscribe = usageBridge.onUpdate?.((nextSnapshot) => {
+ if (!cancelled) applySnapshot(nextSnapshot);
+ });
+ const refreshIfMounted = (force: boolean) => {
+ if (cancelled) return;
+ const currentRequest = ++requestSerial;
+ void readSnapshot(force).then(({ failed, snapshot: nextSnapshot }) => {
+ if (cancelled) return;
+ if (failed && snapshotRef.current) return;
+ const isLatestRequest = currentRequest === requestSerial;
+ const isInitialCachedSnapshot = !force && snapshotRef.current == null;
+ if (isLatestRequest || isInitialCachedSnapshot) applySnapshot(nextSnapshot);
});
- }, open ? 0 : HEADER_USAGE_STARTUP_DELAY_MS);
+ };
+
+ // Get any cached value onto the button immediately, then force a poll so a
+ // cold app launch does not wait for the drawer to be opened.
+ refreshIfMounted(false);
+ refreshIfMounted(true);
+
+ const interval = window.setInterval(() => {
+ refreshIfMounted(true);
+ }, HEADER_USAGE_REFRESH_INTERVAL_MS);
+
+ const refreshOnFocus = () => {
+ if (cancelled) return;
+ const latestSnapshot = snapshotRef.current;
+ const lastPolledAt = latestSnapshot?.lastPolledAt ? new Date(latestSnapshot.lastPolledAt).getTime() : 0;
+ if (!lastPolledAt || Date.now() - lastPolledAt >= HEADER_USAGE_FOCUS_REFRESH_STALE_MS) {
+ refreshIfMounted(true);
+ }
+ };
+ window.addEventListener("focus", refreshOnFocus);
+
return () => {
cancelled = true;
- window.clearTimeout(timer);
+ window.clearInterval(interval);
+ window.removeEventListener("focus", refreshOnFocus);
unsubscribe?.();
};
- }, [open]);
+ }, [applySnapshot]);
// Fetch provider connection status so we can hide providers whose CLI is
// not installed on this machine.
useEffect(() => {
if (!window.ade?.ai?.getStatus) return;
+ const aiBridge = window.ade.ai;
let cancelled = false;
- const timer = window.setTimeout(() => {
- window.ade.ai.getStatus()
+ const loadProviderStatus = () => {
+ aiBridge.getStatus()
.then((status) => {
if (!cancelled) setProviderConnections(status.providerConnections ?? null);
})
.catch(() => {
if (!cancelled) setProviderConnections(null);
});
- }, open ? 0 : HEADER_USAGE_STARTUP_DELAY_MS);
+ };
+ loadProviderStatus();
+ const interval = window.setInterval(loadProviderStatus, HEADER_USAGE_PROVIDER_STATUS_REFRESH_MS);
return () => {
cancelled = true;
- window.clearTimeout(timer);
+ window.clearInterval(interval);
};
- }, [open]);
+ }, []);
// Listen for programmatic open requests (used by the threshold toast).
useEffect(() => {
@@ -116,23 +219,6 @@ export function HeaderUsageControl() {
);
}, [providerConnections]);
- // Refresh on drawer open so the snapshot reflects current usage without
- // waiting for the next background poll.
- useEffect(() => {
- if (!open || !window.ade?.usage?.refresh) return;
- let cancelled = false;
- void window.ade.usage.refresh()
- .then((next) => {
- if (!cancelled && next) setSnapshot(next);
- })
- .catch(() => {
- // Refresh errors are surfaced by the drawer itself.
- });
- return () => {
- cancelled = true;
- };
- }, [open]);
-
useEffect(() => {
if (!open || !window.ade?.usage?.getBudgetConfig) return;
let cancelled = false;
@@ -175,20 +261,17 @@ export function HeaderUsageControl() {
}, [open]);
const providersWithUsage = useMemo(
- () =>
- detectedProviders.map((provider) => ({
- provider,
- percent: weeklyPercentFor(snapshot, provider),
- })),
+ () => detectedProviders.map((provider) => ({
+ provider,
+ usage: headerUsageFor(snapshot, provider),
+ })),
[detectedProviders, snapshot],
);
const hasErrors = (snapshot?.errors.length ?? 0) > 0;
- const titleParts: string[] = [];
- for (const { provider, percent } of providersWithUsage) {
- if (percent == null) continue;
- titleParts.push(`${PROVIDER_LABEL[provider]} ${Math.round(percent)}%`);
- }
+ const titleParts = providersWithUsage.map(
+ ({ provider, usage }) => `${PROVIDER_LABEL[provider]} ${formatUsageTitle(usage)}`,
+ );
let buttonTitle: string;
if (titleParts.length > 0) {
buttonTitle = `Usage · ${titleParts.join(" · ")}${hasErrors ? " · warnings" : ""}`;
@@ -198,14 +281,6 @@ export function HeaderUsageControl() {
buttonTitle = "Usage";
}
- // Render per-provider chips only when (a) we have at least one detected
- // provider AND (b) we have a real percent for at least one of them.
- // Otherwise we fall back to the gauge icon — empty em-dashes ("Claude —")
- // are worse UX than a single neutral icon during the initial poll.
- const hasAnyChip =
- providersWithUsage.length > 0 &&
- providersWithUsage.some(({ percent }) => percent != null);
-
return (
<>
-
+
;
- ai: Pick;
-};
-
-function makeSnapshot(): UsageSnapshot {
- return {
- windows: [
- {
- provider: "codex",
- windowType: "weekly",
- percentUsed: 63,
- resetsAt: "2099-05-15T07:00:00.000Z",
- resetsInMs: 86_400_000,
- },
- {
- provider: "claude",
- windowType: "weekly",
- percentUsed: 20,
- resetsAt: "2099-05-15T07:00:00.000Z",
- resetsInMs: 86_400_000,
- },
- ],
- pacing: {
- status: "on-track",
- projectedWeeklyPercent: 63,
- weekElapsedPercent: 50,
- expectedPercent: 50,
- deltaPercent: 13,
- etaHours: null,
- willLastToReset: true,
- resetsInHours: 24,
- },
- pacingByProvider: {
- codex: {
- status: "ahead",
- projectedWeeklyPercent: 70,
- weekElapsedPercent: 50,
- expectedPercent: 50,
- deltaPercent: 13,
- etaHours: null,
- willLastToReset: true,
- resetsInHours: 24,
- },
- },
- costs: [],
- extraUsage: [],
- lastPolledAt: "2026-05-08T07:00:00.000Z",
- errors: [],
- };
-}
-
-function makeProviderConnection(
- provider: AiProviderConnectionStatus["provider"],
- overrides: Partial = {},
-): AiProviderConnectionStatus {
- return {
- provider,
- authAvailable: false,
- runtimeDetected: false,
- runtimeAvailable: false,
- usageAvailable: false,
- path: null,
- blocker: null,
- lastCheckedAt: "2026-05-08T07:00:00.000Z",
- sources: [],
- ...overrides,
- };
-}
-
-function makeAiStatus(providerConnections: Partial = {}): AiSettingsStatus {
- return {
- mode: "guest",
- availableProviders: {
- claude: {
- binary: { present: false, source: "missing", path: null },
- auth: { ready: false, mode: "none", detail: null },
- },
- codex: false,
- cursor: false,
- droid: false,
- },
- models: {
- claude: [],
- codex: [],
- cursor: [],
- droid: [],
- },
- features: [],
- providerConnections: {
- // Both CLIs detected by default so the panel renders both cards.
- claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: true }),
- codex: makeProviderConnection("codex", { runtimeDetected: true, authAvailable: true }),
- cursor: makeProviderConnection("cursor"),
- droid: makeProviderConnection("droid"),
- ...providerConnections,
- },
- };
-}
-
-describe("UsageQuotaPanel", () => {
- const originalAde = globalThis.window.ade;
-
- beforeEach(() => {
- const snapshot = makeSnapshot();
- const bridge = {
- usage: {
- getSnapshot: vi.fn<[], Promise>(async () => snapshot),
- refresh: vi.fn<[], Promise>(async () => snapshot),
- getBudgetConfig: vi.fn<[], Promise>(async () => ({})),
- saveBudgetConfig: vi.fn<[BudgetCapConfig], Promise>(async (config) => config),
- onUpdate: vi.fn<[(snapshot: UsageSnapshot) => void], () => void>(() => () => {}),
- },
- ai: {
- getStatus: vi.fn<[], Promise>(async () => makeAiStatus()),
- },
- } satisfies UsageQuotaPanelTestBridge;
- Object.assign(globalThis.window, { ade: bridge });
- });
-
- afterEach(() => {
- cleanup();
- globalThis.window.ade = originalAde;
- });
-
- it("renders the weekly used percent for each authed provider", async () => {
- render();
-
- expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0);
- expect(await screen.findByText("63.0% used")).toBeTruthy();
- expect(screen.queryByText("37.0% remaining")).toBeNull();
- });
-
- it("renders weekly and monthly windows as separate meters", async () => {
- const snapshot = makeSnapshot();
- snapshot.windows = [
- ...snapshot.windows,
- {
- provider: "codex",
- windowType: "monthly",
- percentUsed: 44,
- resetsAt: "2099-06-01T07:00:00.000Z",
- resetsInMs: 7 * 86_400_000,
- },
- ];
- vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot);
- vi.mocked(window.ade.usage.refresh).mockResolvedValue(snapshot);
-
- render();
-
- expect((await screen.findAllByText("Weekly")).length).toBeGreaterThan(0);
- expect(await screen.findByText("Monthly")).toBeTruthy();
- expect(await screen.findByText("44.0% used")).toBeTruthy();
- });
-
- it("auto-refreshes once on mount so the drawer never shows stale data", async () => {
- render();
-
- await waitFor(() => {
- expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1);
- });
- });
-
- it("keeps live provider polling available through the manual refresh button", async () => {
- render();
-
- await waitFor(() => {
- const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement;
- expect(refreshButton.disabled).toBe(false);
- });
-
- const baseline = vi.mocked(window.ade.usage.refresh).mock.calls.length;
- fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
-
- await waitFor(() => {
- expect(window.ade.usage.refresh).toHaveBeenCalledTimes(baseline + 1);
- });
- });
-
- it("hides providers whose CLI is not detected on this machine", async () => {
- vi.mocked(window.ade.ai.getStatus).mockResolvedValue(
- makeAiStatus({
- claude: makeProviderConnection("claude", { runtimeDetected: false, authAvailable: false }),
- }),
- );
-
- render();
-
- // Codex card stays visible.
- expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0);
- // Claude card is hidden when the CLI is not installed.
- expect(screen.queryByText("Claude")).toBeNull();
- });
-
- it("dims the provider card when the CLI is installed but not signed in", async () => {
- vi.mocked(window.ade.ai.getStatus).mockResolvedValue(
- makeAiStatus({
- claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: false }),
- }),
- );
-
- render();
-
- expect(await screen.findByText("Not signed in")).toBeTruthy();
- // The weekly bar is not rendered for the unauthed provider.
- expect(screen.queryByText("20.0% used")).toBeNull();
- });
-
- it("never renders a Cursor section", async () => {
- render();
-
- await waitFor(() => {
- expect(window.ade.usage.refresh).toHaveBeenCalled();
- });
- expect(screen.queryByText("Cursor")).toBeNull();
- expect(screen.queryByText(/Cursor not detected/i)).toBeNull();
- });
-});
diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx
new file mode 100644
index 000000000..0fa41e360
--- /dev/null
+++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx
@@ -0,0 +1,342 @@
+/* @vitest-environment jsdom */
+
+import React from "react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ AiProviderConnectionStatus,
+ AiProviderConnections,
+ AiSettingsStatus,
+ BudgetCapConfig,
+ UsageSnapshot,
+} from "../../../shared/types";
+import { HeaderUsageControl } from "./HeaderUsageControl";
+import { UsageQuotaPanel } from "./UsageQuotaPanel";
+
+type UsageComponentTestBridge = {
+ usage: Pick<
+ Window["ade"]["usage"],
+ "getSnapshot" | "refresh" | "getBudgetConfig" | "saveBudgetConfig" | "onUpdate"
+ >;
+ ai: Pick;
+};
+
+function makeEmptySnapshot(): UsageSnapshot {
+ return {
+ windows: [],
+ pacing: {
+ status: "on-track",
+ projectedWeeklyPercent: 0,
+ weekElapsedPercent: 0,
+ expectedPercent: 0,
+ deltaPercent: 0,
+ etaHours: null,
+ willLastToReset: true,
+ resetsInHours: 0,
+ },
+ pacingByProvider: {},
+ costs: [],
+ extraUsage: [],
+ lastPolledAt: "2026-05-21T19:00:00.000Z",
+ errors: [],
+ };
+}
+
+function makeQuotaPanelSnapshot(): UsageSnapshot {
+ return {
+ windows: [
+ {
+ provider: "codex",
+ windowType: "weekly",
+ percentUsed: 63,
+ resetsAt: "2099-05-15T07:00:00.000Z",
+ resetsInMs: 86_400_000,
+ },
+ {
+ provider: "claude",
+ windowType: "weekly",
+ percentUsed: 20,
+ resetsAt: "2099-05-15T07:00:00.000Z",
+ resetsInMs: 86_400_000,
+ },
+ ],
+ pacing: {
+ status: "on-track",
+ projectedWeeklyPercent: 63,
+ weekElapsedPercent: 50,
+ expectedPercent: 50,
+ deltaPercent: 13,
+ etaHours: null,
+ willLastToReset: true,
+ resetsInHours: 24,
+ },
+ pacingByProvider: {
+ codex: {
+ status: "ahead",
+ projectedWeeklyPercent: 70,
+ weekElapsedPercent: 50,
+ expectedPercent: 50,
+ deltaPercent: 13,
+ etaHours: null,
+ willLastToReset: true,
+ resetsInHours: 24,
+ },
+ },
+ costs: [],
+ extraUsage: [],
+ lastPolledAt: "2026-05-08T07:00:00.000Z",
+ errors: [],
+ };
+}
+
+function makeHeaderUsageSnapshot(): UsageSnapshot {
+ return {
+ ...makeEmptySnapshot(),
+ windows: [
+ {
+ provider: "codex",
+ windowType: "five_hour",
+ percentUsed: 9,
+ resetsAt: "2099-05-21T20:38:05.000Z",
+ resetsInMs: 4_600_000,
+ },
+ {
+ provider: "codex",
+ windowType: "weekly",
+ percentUsed: 19,
+ resetsAt: "2099-05-26T18:36:31.000Z",
+ resetsInMs: 429_300_000,
+ },
+ ],
+ lastPolledAt: "2026-05-21T19:21:26.424Z",
+ };
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((next) => {
+ resolve = next;
+ });
+ return { promise, resolve };
+}
+
+function makeProviderConnection(
+ provider: AiProviderConnectionStatus["provider"],
+ overrides: Partial = {},
+): AiProviderConnectionStatus {
+ return {
+ provider,
+ authAvailable: false,
+ runtimeDetected: false,
+ runtimeAvailable: false,
+ usageAvailable: false,
+ path: null,
+ blocker: null,
+ lastCheckedAt: "2026-05-08T07:00:00.000Z",
+ sources: [],
+ ...overrides,
+ };
+}
+
+function makeAiStatus(providerConnections: Partial = {}): AiSettingsStatus {
+ return {
+ mode: "guest",
+ availableProviders: {
+ claude: {
+ binary: { present: false, source: "missing", path: null },
+ auth: { ready: false, mode: "none", detail: null },
+ },
+ codex: false,
+ cursor: false,
+ droid: false,
+ },
+ models: {
+ claude: [],
+ codex: [],
+ cursor: [],
+ droid: [],
+ },
+ features: [],
+ providerConnections: {
+ // Both CLIs detected by default so usage components render both cards.
+ claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: true }),
+ codex: makeProviderConnection("codex", { runtimeDetected: true, authAvailable: true }),
+ cursor: makeProviderConnection("cursor"),
+ droid: makeProviderConnection("droid"),
+ ...providerConnections,
+ },
+ };
+}
+
+describe("usage components", () => {
+ const originalAde = globalThis.window.ade;
+
+ beforeEach(() => {
+ const snapshot = makeQuotaPanelSnapshot();
+ const bridge = {
+ usage: {
+ getSnapshot: vi.fn<[], Promise>(async () => snapshot),
+ refresh: vi.fn<[], Promise>(async () => snapshot),
+ getBudgetConfig: vi.fn<[], Promise>(async () => ({})),
+ saveBudgetConfig: vi.fn<[BudgetCapConfig], Promise>(async (config) => config),
+ onUpdate: vi.fn<[(snapshot: UsageSnapshot) => void], () => void>(() => () => {}),
+ },
+ ai: {
+ getStatus: vi.fn<[], Promise>(async () => makeAiStatus()),
+ },
+ } satisfies UsageComponentTestBridge;
+ Object.assign(globalThis.window, { ade: bridge });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ globalThis.window.ade = originalAde;
+ });
+
+ describe("UsageQuotaPanel", () => {
+ it("renders the weekly used percent for each authed provider", async () => {
+ render();
+
+ expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0);
+ expect(await screen.findByText("63.0% used")).toBeTruthy();
+ expect(screen.queryByText("37.0% remaining")).toBeNull();
+ });
+
+ it("renders weekly and monthly windows as separate meters", async () => {
+ const snapshot = makeQuotaPanelSnapshot();
+ snapshot.windows = [
+ ...snapshot.windows,
+ {
+ provider: "codex",
+ windowType: "monthly",
+ percentUsed: 44,
+ resetsAt: "2099-06-01T07:00:00.000Z",
+ resetsInMs: 7 * 86_400_000,
+ },
+ ];
+ vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot);
+ vi.mocked(window.ade.usage.refresh).mockResolvedValue(snapshot);
+
+ render();
+
+ expect((await screen.findAllByText("Weekly")).length).toBeGreaterThan(0);
+ expect(await screen.findByText("Monthly")).toBeTruthy();
+ expect(await screen.findByText("44.0% used")).toBeTruthy();
+ });
+
+ it("auto-refreshes once on mount so the drawer never shows stale data", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("keeps live provider polling available through the manual refresh button", async () => {
+ render();
+
+ await waitFor(() => {
+ const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement;
+ expect(refreshButton.disabled).toBe(false);
+ });
+
+ const baseline = vi.mocked(window.ade.usage.refresh).mock.calls.length;
+ fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
+
+ await waitFor(() => {
+ expect(window.ade.usage.refresh).toHaveBeenCalledTimes(baseline + 1);
+ });
+ });
+
+ it("hides providers whose CLI is not detected on this machine", async () => {
+ vi.mocked(window.ade.ai.getStatus).mockResolvedValue(
+ makeAiStatus({
+ claude: makeProviderConnection("claude", { runtimeDetected: false, authAvailable: false }),
+ }),
+ );
+
+ render();
+
+ expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0);
+ expect(screen.queryByText("Claude")).toBeNull();
+ });
+
+ it("dims the provider card when the CLI is installed but not signed in", async () => {
+ vi.mocked(window.ade.ai.getStatus).mockResolvedValue(
+ makeAiStatus({
+ claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: false }),
+ }),
+ );
+
+ render();
+
+ expect(await screen.findByText("Not signed in")).toBeTruthy();
+ expect(screen.queryByText("20.0% used")).toBeNull();
+ });
+
+ it("never renders a Cursor section", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(window.ade.usage.refresh).toHaveBeenCalled();
+ });
+ expect(screen.queryByText("Cursor")).toBeNull();
+ expect(screen.queryByText(/Cursor not detected/i)).toBeNull();
+ });
+ });
+
+ describe("HeaderUsageControl", () => {
+ beforeEach(() => {
+ vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(makeEmptySnapshot());
+ vi.mocked(window.ade.usage.refresh).mockResolvedValue(makeHeaderUsageSnapshot());
+ vi.mocked(window.ade.ai.getStatus).mockResolvedValue(
+ makeAiStatus({
+ claude: makeProviderConnection("claude", { runtimeDetected: false, authAvailable: false }),
+ codex: makeProviderConnection("codex", { runtimeDetected: true, authAvailable: true }),
+ }),
+ );
+ });
+
+ it("force-refreshes on mount and renders top-bar usage numbers before the drawer is opened", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ expect(await screen.findByText("9%")).toBeTruthy();
+ expect(await screen.findByText("19%")).toBeTruthy();
+ expect(screen.getByRole("button", { name: /Codex 5h 9%, wk 19%/ })).toBeTruthy();
+ });
+
+ it("does not let a slower cached startup read overwrite the fresh refresh result", async () => {
+ const cachedSnapshot = deferred();
+ vi.mocked(window.ade.usage.getSnapshot).mockReturnValue(cachedSnapshot.promise);
+ vi.mocked(window.ade.usage.refresh).mockResolvedValue(makeHeaderUsageSnapshot());
+
+ render();
+
+ expect(await screen.findByText("9%")).toBeTruthy();
+
+ await act(async () => {
+ cachedSnapshot.resolve(makeEmptySnapshot());
+ await cachedSnapshot.promise;
+ });
+
+ expect(screen.getByText("9%")).toBeTruthy();
+ expect(screen.getByText("19%")).toBeTruthy();
+ });
+
+ it("keeps refreshing while the drawer stays closed", async () => {
+ vi.useFakeTimers();
+ render();
+
+ expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(120_000);
+
+ expect(window.ade.usage.refresh).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md
index 2fb733072..88d2267b7 100644
--- a/docs/features/onboarding-and-settings/README.md
+++ b/docs/features/onboarding-and-settings/README.md
@@ -160,8 +160,8 @@ Renderer — settings:
- `apps/desktop/src/renderer/components/app/SettingsPage.tsx` — tab
container. The current top-level sections are General, Appearance,
- Workspace, AI, Mobile Push, Integrations, Memory, Lane Templates,
- and Usage. Onboarding / Help / Tours route deep links land in
+ Workspace, AI, Mobile Push, Integrations, Memory, and Lane Templates.
+ Onboarding / Help / Tours route deep links land in
General (`TAB_ALIASES`); tutorial replay and tour entry points live
under the Help menu in the top bar, not as a Settings tab. The
legacy `OnboardingSection` was removed — its surface lives in the
@@ -210,17 +210,27 @@ Renderer — settings:
and `UsageQuotaPanel.tsx` — header usage popup. Live provider quotas
for Claude and Codex (tracked providers) and the automation budget
guardrails are now consolidated here; Settings no longer has a
- Usage tab. The header shows weekly-window peaks per tracked
- provider with green/amber/red thresholds at 75% / 100%; the panel
- drills down into all reset windows, last-poll status, and per-
- provider error chips. Cursor usage polling was removed (it required
- a team-admin API key that desktop users almost never have); only
- `claude` and `codex` are tracked in `TRACKED_PROVIDERS`. The popup
- hydrates from `ade.usage.getSnapshot` and re-fetches via the
- explicit Refresh control. Budget caps round-trip through
- `ade.usage.getBudgetConfig` / `saveBudgetConfig`. Threshold
- crossings (25 / 50 / 75 / 100 %) emit `UsageThresholdEvent`s the
- notification bus turns into APNs alerts.
+ Usage tab. The header renders one compact chip per detected tracked
+ provider with the 5-hour window and the plan window (`wk` when a
+ weekly window is present, otherwise `mo`). Percent values are clamped
+ to 0-100, color through the green/amber/red thresholds at 75% /
+ 100%, and show an ellipsis while missing. On mount, the button reads
+ the cached `ade.usage.getSnapshot`, immediately forces
+ `ade.usage.refresh`, ignores a slower cached startup read when a
+ fresher forced refresh has already landed, refreshes every 120 s, and
+ refreshes on window focus when the latest poll is older than 60 s.
+ Provider detection comes from `ade.ai.getStatus` on mount and every
+ 5 min; CLIs not detected on the machine are hidden from the header,
+ while installed-but-unauthenticated providers stay visible in the
+ panel as "Not signed in". The panel auto-refreshes on open, subscribes
+ to usage `onUpdate`, and drills down into 5-hour, weekly, monthly,
+ and other reset windows, last-poll status, daily 7-day usage, and
+ per-provider error chips. Cursor usage polling was removed (it
+ required a team-admin API key that desktop users almost never have);
+ only `claude` and `codex` are tracked in `TRACKED_PROVIDERS`. Budget
+ caps round-trip through `ade.usage.getBudgetConfig` /
+ `saveBudgetConfig`. Threshold crossings (25 / 50 / 75 / 100 %) emit
+ `UsageThresholdEvent`s the notification bus turns into APNs alerts.
- `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx`
— proxy/preview configuration UI.
- `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx`