From 898e8f83d5030c605689193e26d9ca36d027abd8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 16:57:09 -0400 Subject: [PATCH] ship: prepare lane for review --- .../components/usage/HeaderUsageControl.tsx | 217 +++++++---- .../components/usage/UsageQuotaPanel.test.tsx | 235 ------------ .../renderer/components/usage/usage.test.tsx | 342 ++++++++++++++++++ .../onboarding-and-settings/README.md | 36 +- 4 files changed, 507 insertions(+), 323 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx create mode 100644 apps/desktop/src/renderer/components/usage/usage.test.tsx 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`