From 9d7286ce0c3e601a53aff77a1827acbe33e1f819 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 26 Sep 2026 20:21:25 +0200 Subject: [PATCH] feat(app): show usage sources in the composer and a Usage screen Replace the provider-matched usage in the context ring tooltip with a composer pill driven by agent.get_usage_report, add a sidebar Usage screen over usage.list_reports grouped by host, and move the host settings Usage page to usage.list_reports. Hosts without features.usageSources show an update message. Source icons come from the report's catalog SVG. The app no longer calls provider.usage.list. --- .../browser/provider-usage-settings.spec.ts | 152 --------------- .../browser/provider-usage-tooltip.spec.ts | 113 ----------- .../e2e/browser/usage-composer-pill.spec.ts | 115 +++++++++++ packages/app/e2e/browser/usage-screen.spec.ts | 58 ++++++ .../app/e2e/browser/usage-settings.spec.ts | 135 +++++++++++++ .../app/e2e/support/helpers/provider-usage.ts | 154 --------------- .../app/e2e/support/helpers/usage-reports.ts | 147 +++++++++++++++ packages/app/src/app/_layout.tsx | 2 + packages/app/src/app/usage.tsx | 10 + .../src/components/context-window-meter.tsx | 32 +--- packages/app/src/components/left-sidebar.tsx | 28 ++- packages/app/src/components/provider-icons.ts | 12 +- packages/app/src/composer/index.tsx | 19 +- packages/app/src/provider-usage/copy.ts | 18 -- .../src/provider-usage/settings-section.tsx | 99 ---------- .../src/provider-usage/tooltip-section.tsx | 75 -------- packages/app/src/provider-usage/types.ts | 26 --- .../src/provider-usage/use-provider-usage.ts | 103 ---------- .../app/src/screens/settings/host-page.tsx | 9 +- .../{provider-usage => usage}/balance-bar.tsx | 8 +- .../src/{provider-usage => usage}/card.tsx | 58 ++---- packages/app/src/usage/composer-pill.tsx | 86 +++++++++ packages/app/src/usage/copy.ts | 16 ++ .../src/{provider-usage => usage}/format.ts | 17 +- packages/app/src/usage/host-usage-section.tsx | 11 ++ packages/app/src/usage/index.ts | 3 + .../src/{provider-usage => usage}/list.tsx | 12 +- packages/app/src/usage/model.test.ts | 170 +++++++++++++++++ packages/app/src/usage/model.ts | 95 ++++++++++ packages/app/src/usage/queries.ts | 178 ++++++++++++++++++ packages/app/src/usage/source-icon.tsx | 26 +++ .../app/src/{provider-usage => usage}/tone.ts | 4 +- packages/app/src/usage/types.ts | 15 ++ packages/app/src/usage/usage-screen.tsx | 70 +++++++ packages/app/src/usage/usage-section.tsx | 93 +++++++++ .../{provider-usage => usage}/window-bar.tsx | 15 +- packages/app/src/utils/host-routes.ts | 4 + 37 files changed, 1313 insertions(+), 875 deletions(-) delete mode 100644 packages/app/e2e/browser/provider-usage-settings.spec.ts delete mode 100644 packages/app/e2e/browser/provider-usage-tooltip.spec.ts create mode 100644 packages/app/e2e/browser/usage-composer-pill.spec.ts create mode 100644 packages/app/e2e/browser/usage-screen.spec.ts create mode 100644 packages/app/e2e/browser/usage-settings.spec.ts delete mode 100644 packages/app/e2e/support/helpers/provider-usage.ts create mode 100644 packages/app/e2e/support/helpers/usage-reports.ts create mode 100644 packages/app/src/app/usage.tsx delete mode 100644 packages/app/src/provider-usage/copy.ts delete mode 100644 packages/app/src/provider-usage/settings-section.tsx delete mode 100644 packages/app/src/provider-usage/tooltip-section.tsx delete mode 100644 packages/app/src/provider-usage/types.ts delete mode 100644 packages/app/src/provider-usage/use-provider-usage.ts rename packages/app/src/{provider-usage => usage}/balance-bar.tsx (91%) rename packages/app/src/{provider-usage => usage}/card.tsx (69%) create mode 100644 packages/app/src/usage/composer-pill.tsx create mode 100644 packages/app/src/usage/copy.ts rename packages/app/src/{provider-usage => usage}/format.ts (63%) create mode 100644 packages/app/src/usage/host-usage-section.tsx create mode 100644 packages/app/src/usage/index.ts rename packages/app/src/{provider-usage => usage}/list.tsx (60%) create mode 100644 packages/app/src/usage/model.test.ts create mode 100644 packages/app/src/usage/model.ts create mode 100644 packages/app/src/usage/queries.ts create mode 100644 packages/app/src/usage/source-icon.tsx rename packages/app/src/{provider-usage => usage}/tone.ts (74%) create mode 100644 packages/app/src/usage/types.ts create mode 100644 packages/app/src/usage/usage-screen.tsx create mode 100644 packages/app/src/usage/usage-section.tsx rename packages/app/src/{provider-usage => usage}/window-bar.tsx (85%) diff --git a/packages/app/e2e/browser/provider-usage-settings.spec.ts b/packages/app/e2e/browser/provider-usage-settings.spec.ts deleted file mode 100644 index 0763233e10..0000000000 --- a/packages/app/e2e/browser/provider-usage-settings.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { gotoAppShell, openSettings } from "../support/helpers/app"; -import { installProviderUsageFixture } from "../support/helpers/provider-usage"; -import { getServerId } from "../support/helpers/server-id"; -import { openSettingsHostSection } from "../support/helpers/settings"; - -test.describe("provider usage settings", () => { - test("renders every provider returned by the daemon usage RPC", async ({ page }) => { - test.setTimeout(120_000); - const serverId = getServerId(); - const usageFixture = await installProviderUsageFixture(page, [ - { - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "claude", - displayName: "Claude", - status: "available", - planLabel: "Max 20x", - windows: [{ id: "session", label: "Session", usedPct: 7 }], - }, - { - providerId: "codex", - displayName: "Codex", - status: "available", - planLabel: "Pro 20x", - windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], - }, - { - providerId: "glm", - displayName: "GLM coding plan", - status: "available", - planLabel: "GLM coding plan", - sourceLabel: "OpenUsage 0.6.27", - windows: [ - { id: "biweekly", label: "Biweekly", usedPct: 23 }, - { id: "daily", label: "Daily", remainingPct: 30 }, - ], - balances: [ - { id: "credits", label: "Credits", remaining: 1234, unit: "credits" }, - { id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" }, - ], - details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }], - }, - ], - }, - ]); - - await gotoAppShell(page); - await openSettings(page); - expect(usageFixture.requestCount()).toBe(0); - await openSettingsHostSection(page, serverId, "usage"); - await usageFixture.waitForRequestCount(1); - - const card = page.getByTestId("provider-usage-card"); - await expect(card).toBeVisible({ timeout: 10_000 }); - await expect(card.getByText("Claude", { exact: true })).toBeVisible(); - await expect(card.getByText("Codex", { exact: true })).toBeVisible(); - await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible(); - await expect(card.getByText("Biweekly", { exact: true })).toBeVisible(); - await expect(card.getByText("Daily", { exact: true })).toBeVisible(); - await expect(card.getByText("70%")).toBeVisible(); - await expect(card.getByText("Credits", { exact: true })).toBeVisible(); - await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); - await expect(card.getByText("Extra usage", { exact: true })).toBeVisible(); - await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); - await expect(card.getByText("Valid until", { exact: true })).toBeVisible(); - await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); - await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible(); - }); - - test("refresh invalidates and refetches usage", async ({ page }) => { - test.setTimeout(120_000); - const serverId = getServerId(); - const usageFixture = await installProviderUsageFixture(page, [ - { - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "glm", - displayName: "GLM coding plan", - status: "available", - planLabel: "GLM coding plan", - windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }], - }, - ], - }, - { - fetchedAt: "2026-06-19T00:01:00.000Z", - providers: [ - { - providerId: "glm", - displayName: "GLM coding plan", - status: "available", - planLabel: "GLM coding plan", - windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }], - }, - ], - }, - ]); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHostSection(page, serverId, "usage"); - await usageFixture.waitForRequestCount(1); - await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); - - await page.getByRole("button", { name: "Refresh", exact: true }).click(); - await usageFixture.waitForRequestCount(2); - - expect(usageFixture.requestCount()).toBe(2); - await expect(page.getByText("64%")).toBeVisible(); - }); - - test("one provider error does not collapse the usage list", async ({ page }) => { - test.setTimeout(120_000); - const serverId = getServerId(); - await installProviderUsageFixture(page, [ - { - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "claude", - displayName: "Claude", - status: "error", - planLabel: null, - windows: [], - error: "Claude auth expired", - }, - { - providerId: "codex", - displayName: "Codex", - status: "available", - planLabel: "Pro 20x", - windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }], - }, - ], - }, - ]); - - await gotoAppShell(page); - await openSettings(page); - await openSettingsHostSection(page, serverId, "usage"); - - const card = page.getByTestId("provider-usage-card"); - await expect(card).toBeVisible({ timeout: 10_000 }); - await expect(card.getByText("Error", { exact: true })).toBeVisible(); - await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible(); - await expect(card.getByText("Codex", { exact: true })).toBeVisible(); - await expect(card.getByText("71%")).toBeVisible(); - }); -}); diff --git a/packages/app/e2e/browser/provider-usage-tooltip.spec.ts b/packages/app/e2e/browser/provider-usage-tooltip.spec.ts deleted file mode 100644 index 680b7b4b80..0000000000 --- a/packages/app/e2e/browser/provider-usage-tooltip.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { expect, test, type Page } from "../support/fixtures"; -import { expectComposerVisible } from "../support/helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { installProviderUsageFixture } from "../support/helpers/provider-usage"; - -const MOBILE_VIEWPORT = { width: 390, height: 844 }; - -async function openMockAgent(page: Page) { - await page.setViewportSize(MOBILE_VIEWPORT); - const session = await seedMockAgentWorkspace({ - repoPrefix: "provider-usage-tooltip-", - title: "Provider usage tooltip e2e", - initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.", - }); - await openAgentRoute(page, session); - await expectComposerVisible(page); - await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 }); - return session; -} - -test.describe("provider usage tooltip", () => { - test("fetches usage when the context tooltip opens and renders the active provider", async ({ - page, - }) => { - test.setTimeout(180_000); - const usageFixture = await installProviderUsageFixture(page, [ - { - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "mock", - displayName: "Mock provider", - status: "available", - planLabel: "Test plan", - windows: [ - { - id: "session", - label: "Session", - usedPct: 42, - remainingPct: 58, - resetsAt: "2026-06-19T05:00:00.000Z", - }, - ], - }, - ], - }, - ]); - const session = await openMockAgent(page); - try { - expect(usageFixture.requestCount()).toBe(0); - - await page.getByTestId("context-window-meter").hover(); - await usageFixture.waitForRequestCount(1); - - await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({ - timeout: 10_000, - }); - await expect(page.getByText("Test plan")).toBeVisible(); - await expect(page.getByText("Session", { exact: true })).toBeVisible(); - await expect(page.getByText("42%")).toBeVisible(); - } finally { - await session.cleanup(); - } - }); - - test("refreshes usage again each time the tooltip is shown", async ({ page }) => { - test.setTimeout(180_000); - const usageFixture = await installProviderUsageFixture(page, [ - { - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "mock", - displayName: "Mock provider", - status: "available", - planLabel: "Test plan", - windows: [{ id: "session", label: "Session", usedPct: 41 }], - }, - ], - }, - { - fetchedAt: "2026-06-19T00:01:00.000Z", - providers: [ - { - providerId: "mock", - displayName: "Mock provider", - status: "available", - planLabel: "Test plan", - windows: [{ id: "session", label: "Session", usedPct: 64 }], - }, - ], - }, - ]); - const session = await openMockAgent(page); - try { - const meter = page.getByTestId("context-window-meter"); - - await meter.hover(); - await usageFixture.waitForRequestCount(1); - await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 }); - - await page.mouse.move(0, 0); - await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0); - - await meter.hover(); - await usageFixture.waitForRequestCount(2); - expect(usageFixture.requestCount()).toBe(2); - await expect(page.getByText("64%")).toBeVisible(); - } finally { - await session.cleanup(); - } - }); -}); diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts new file mode 100644 index 0000000000..beb9468925 --- /dev/null +++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts @@ -0,0 +1,115 @@ +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +function agentEntry(usedPct: number): UsageReportEntry { + return { + sourceId: "fixture", + sourceLabel: "Fixture plan", + icon: '', + report: { + account: { key: "fixture-account" }, + status: "available", + planLabel: "Test plan", + windows: [ + { id: "session", label: "Session", usedPct: 3 }, + { id: "weekly", label: "Weekly", usedPct, headline: true }, + ], + }, + }; +} + +async function openMockAgent(page: Page) { + const session = await seedMockAgentWorkspace({ + repoPrefix: "usage-composer-pill-", + title: "Usage composer pill e2e", + initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.", + }); + await openAgentRoute(page, session); + await expectComposerVisible(page); + return session; +} + +test.describe("usage composer pill", () => { + test("shows the headline percent and opens the usage card with a forced refresh", async ({ + page, + }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentEntries: [agentEntry(42), agentEntry(64)], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId, forceRefresh: false }); + + const requestsBeforeOpen = usage.agentRequests().length; + await pill.click(); + await usage.waitForAgentRequests(requestsBeforeOpen + 1); + expect(usage.agentRequests().at(-1)?.forceRefresh).toBe(true); + + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible(); + await expect(popover.getByText("Test plan")).toBeVisible(); + await expect(popover.getByText("64%")).toBeVisible(); + await expect(pill).toContainText("64%"); + } finally { + await session.cleanup(); + } + }); + + test("refetches the agent's report when a turn completes", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentEntries: [agentEntry(42), agentEntry(77)], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + const requestsBeforeTurn = usage.agentRequests().length; + + await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill."); + + await usage.waitForAgentRequests(requestsBeforeTurn + 1); + expect(usage.agentRequests().at(-1)).toEqual({ + agentId: session.agentId, + forceRefresh: false, + }); + await expect(pill).toContainText("77%"); + } finally { + await session.cleanup(); + } + }); + + test("is hidden when the agent has no usage report", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { agentEntries: [null] }); + const session = await openMockAgent(page); + try { + await usage.waitForAgentRequests(1); + await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); + } finally { + await session.cleanup(); + } + }); + + test("is hidden on a host without usage sources", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + usageSources: false, + agentEntries: [agentEntry(42)], + }); + const session = await openMockAgent(page); + try { + await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); + expect(usage.agentRequests()).toHaveLength(0); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts new file mode 100644 index 0000000000..cc180c4706 --- /dev/null +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +test.describe("usage screen", () => { + test("opens from the sidebar and groups reports under their host", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + { + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + account: { key: "a" }, + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }], + }, + }, + { + sourceId: "beta", + sourceLabel: "Beta plan", + report: { account: { key: "b" }, status: "unavailable", windows: [] }, + }, + ], + ], + }); + + await gotoAppShell(page); + await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await expect(page).toHaveURL(/\/usage$/); + await usage.waitForListRequests(1); + + const group = page.getByTestId(`usage-host-${serverId}`); + await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(group.getByText("31%")).toBeVisible(); + await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); + await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); + }); + + test("tells the user to update a host without usage sources", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { usageSources: false }); + + await gotoAppShell(page); + await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + + await expect( + page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", { + exact: true, + }), + ).toBeVisible({ timeout: 10_000 }); + expect(usage.listRequests()).toHaveLength(0); + }); +}); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts new file mode 100644 index 0000000000..c825f76c3c --- /dev/null +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -0,0 +1,135 @@ +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHostSection } from "../support/helpers/settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +const ICON = ''; + +function report(input: { + sourceId: string; + sourceLabel: string; + report: Partial; +}): UsageReportEntry { + return { + sourceId: input.sourceId, + sourceLabel: input.sourceLabel, + icon: ICON, + report: { + account: { key: `${input.sourceId}-account` }, + status: "available", + windows: [], + ...input.report, + }, + }; +} + +test.describe("usage settings", () => { + test("renders every report returned by usage.list_reports", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { + planLabel: "Max", + account: { key: "a", label: "dev@example.com" }, + windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }], + }, + }), + report({ + sourceId: "beta", + sourceLabel: "Beta plan", + report: { + planLabel: "Coding plan", + windows: [ + { id: "biweekly", label: "Biweekly", usedPct: 23 }, + { id: "daily", label: "Daily", remainingPct: 30 }, + ], + balances: [ + { id: "credits", label: "Credits", remaining: 1234, unit: "credits" }, + { id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" }, + ], + details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }], + }, + }), + report({ + sourceId: "gamma", + sourceLabel: "Gamma plan", + report: { status: "error", error: "Gamma auth expired" }, + }), + ], + ], + }); + + await gotoAppShell(page); + await openSettings(page); + expect(usage.listRequests()).toHaveLength(0); + await openSettingsHostSection(page, serverId, "usage"); + await usage.waitForListRequests(1); + + const card = page.getByTestId("usage-card"); + await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible(); + await expect(card.getByText("Beta plan", { exact: true })).toBeVisible(); + await expect(card.getByText("70%")).toBeVisible(); + await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); + await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); + await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); + await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); + }); + + test("refresh forces a fresh report", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }]; + const usage = await installUsageReportsFixture(page, { + lists: [ + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { windows: windows(23) }, + }), + ], + [ + report({ + sourceId: "alpha", + sourceLabel: "Alpha plan", + report: { windows: windows(64) }, + }), + ], + ], + }); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await usage.waitForListRequests(2); + + expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true }); + await expect(page.getByText("64%")).toBeVisible(); + }); + + test("asks to update a host without usage sources and never calls it", async ({ page }) => { + test.setTimeout(120_000); + const serverId = getServerId(); + const usage = await installUsageReportsFixture(page, { usageSources: false }); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + + await expect( + page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }), + ).toBeVisible({ timeout: 10_000 }); + expect(usage.listRequests()).toHaveLength(0); + }); +}); diff --git a/packages/app/e2e/support/helpers/provider-usage.ts b/packages/app/e2e/support/helpers/provider-usage.ts deleted file mode 100644 index 9ce3bc8849..0000000000 --- a/packages/app/e2e/support/helpers/provider-usage.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Page } from "@playwright/test"; -import type { ProviderUsage } from "@getpaseo/protocol/messages"; -import { daemonWsRoutePattern } from "./daemon-port"; - -interface ProviderUsageFixturePayload { - fetchedAt: string; - providers: ProviderUsage[]; -} - -export interface ProviderUsageFixture { - requestCount(): number; - waitForRequestCount(count: number): Promise; -} - -type WebSocketMessage = string | Buffer; - -function parseJson(message: WebSocketMessage): unknown { - const raw = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - -function withProviderUsageFeature(message: WebSocketMessage): string | null { - const envelope = parseJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { - type?: unknown; - message?: { - type?: unknown; - payload?: Record; - }; - }; - const payload = maybeEnvelope.message?.payload; - if ( - maybeEnvelope.type !== "session" || - maybeEnvelope.message?.type !== "status" || - payload?.status !== "server_info" - ) { - return null; - } - return JSON.stringify({ - ...maybeEnvelope, - message: { - ...maybeEnvelope.message, - payload: { - ...payload, - features: { - ...(typeof payload.features === "object" && payload.features !== null - ? payload.features - : {}), - providerUsageList: true, - }, - }, - }, - }); -} - -export async function installProviderUsageFixture( - page: Page, - payloads: ProviderUsageFixturePayload[], -): Promise { - let requests = 0; - const waiters: Array<{ count: number; resolve: () => void }> = []; - - function notifyWaiters() { - for (const waiter of waiters.splice(0)) { - if (requests >= waiter.count) { - waiter.resolve(); - } else { - waiters.push(waiter); - } - } - } - - function payloadForRequest(): ProviderUsageFixturePayload { - const index = Math.min(requests - 1, payloads.length - 1); - const payload = payloads[index]; - if (!payload) { - throw new Error("Provider usage fixture requires at least one payload."); - } - return payload; - } - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "provider.usage.list.request") { - requests += 1; - const requestId = sessionMessage.requestId; - if (typeof requestId !== "string") { - throw new Error("provider.usage.list.request missing requestId"); - } - const payload = payloadForRequest(); - notifyWaiters(); - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "provider.usage.list.response", - payload: { - requestId, - fetchedAt: payload.fetchedAt, - providers: payload.providers, - }, - }, - }), - ); - return; - } - server.send(message); - }); - - server.onMessage((message) => { - const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null; - ws.send(serverInfo ?? message); - }); - }); - - return { - requestCount() { - return requests; - }, - waitForRequestCount(count: number) { - if (requests >= count) { - return Promise.resolve(); - } - return new Promise((resolve) => { - waiters.push({ count, resolve }); - }); - }, - }; -} diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts new file mode 100644 index 0000000000..5b5938c157 --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -0,0 +1,147 @@ +import type { Page } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { daemonWsRoutePattern } from "./daemon-port"; + +export interface UsageReportsFixture { + listRequests(): Array<{ forceRefresh: boolean }>; + agentRequests(): Array<{ agentId: string; forceRefresh: boolean }>; + waitForListRequests(count: number): Promise; + waitForAgentRequests(count: number): Promise; +} + +interface UsageReportsFixtureOptions { + /** Successive `usage.list_reports` responses; the last one repeats. */ + lists?: UsageReportEntry[][]; + /** Successive `agent.get_usage_report` responses; the last one repeats. */ + agentEntries?: Array; + /** Advertise `features.usageSources`. False simulates a host from before usage sources. */ + usageSources?: boolean; +} + +type WebSocketMessage = string | Buffer; + +function parseJson(message: WebSocketMessage): unknown { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null; + if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") { + return null; + } + return envelope.message as Record; +} + +function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null { + const envelope = parseJson(message) as { + type?: unknown; + message?: { type?: unknown; payload?: Record }; + } | null; + const payload = envelope?.message?.payload; + if ( + envelope?.type !== "session" || + envelope.message?.type !== "status" || + payload?.status !== "server_info" + ) { + return null; + } + const features = + typeof payload.features === "object" && payload.features !== null ? payload.features : {}; + return JSON.stringify({ + ...envelope, + message: { + ...envelope.message, + payload: { ...payload, features: { ...features, usageSources: enabled } }, + }, + }); +} + +function pick(values: T[], index: number): T { + const value = values[Math.min(index, values.length - 1)]; + if (value === undefined) throw new Error("Usage fixture requires at least one response."); + return value; +} + +function createCounter() { + let count = 0; + const waiters: Array<{ count: number; resolve: () => void }> = []; + return { + increment() { + count += 1; + for (const waiter of waiters.splice(0)) { + if (count >= waiter.count) waiter.resolve(); + else waiters.push(waiter); + } + }, + waitFor(target: number): Promise { + if (count >= target) return Promise.resolve(); + return new Promise((resolve) => waiters.push({ count: target, resolve })); + }, + }; +} + +export async function installUsageReportsFixture( + page: Page, + options: UsageReportsFixtureOptions, +): Promise { + const listRequests: Array<{ forceRefresh: boolean }> = []; + const agentRequests: Array<{ agentId: string; forceRefresh: boolean }> = []; + const listCounter = createCounter(); + const agentCounter = createCounter(); + const usageSources = options.usageSources ?? true; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const request = getSessionMessage(message); + const requestId = request?.requestId; + if (request?.type === "usage.list_reports.request" && typeof requestId === "string") { + listRequests.push({ forceRefresh: request.forceRefresh === true }); + const reports = pick(options.lists ?? [[]], listRequests.length - 1); + ws.send( + JSON.stringify({ + type: "session", + message: { type: "usage.list_reports.response", payload: { requestId, reports } }, + }), + ); + listCounter.increment(); + return; + } + if (request?.type === "agent.get_usage_report.request" && typeof requestId === "string") { + agentRequests.push({ + agentId: String(request.agentId), + forceRefresh: request.forceRefresh === true, + }); + const entry = pick(options.agentEntries ?? [null], agentRequests.length - 1); + ws.send( + JSON.stringify({ + type: "session", + message: { type: "agent.get_usage_report.response", payload: { requestId, entry } }, + }), + ); + agentCounter.increment(); + return; + } + server.send(message); + }); + + server.onMessage((message) => { + const serverInfo = + typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null; + ws.send(serverInfo ?? message); + }); + }); + + return { + listRequests: () => [...listRequests], + agentRequests: () => [...agentRequests], + waitForListRequests: (count) => listCounter.waitFor(count), + waitForAgentRequests: (count) => agentCounter.waitFor(count), + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 59b198bdf5..cf682e7611 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || + pathname === "/usage" || routeHasKnownHost); return {children}; @@ -892,6 +893,7 @@ function RootStack() { + diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx new file mode 100644 index 0000000000..2928cca3f3 --- /dev/null +++ b/packages/app/src/app/usage.tsx @@ -0,0 +1,10 @@ +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { UsageScreen } from "@/usage"; + +export default function UsageRoute() { + return ( + + + + ); +} diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 6fe837562e..e587123f19 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,11 +1,8 @@ -import { useCallback, useState } from "react"; import { Pressable, Text, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section"; -import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { @@ -13,9 +10,6 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - serverId?: string; - /** The Paseo provider key, e.g. "claude", "gemini", "codex" */ - provider?: string | null; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -101,30 +95,13 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, - serverId, - provider, pending = false, glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const [isTooltipOpen, setIsTooltipOpen] = useState(false); - const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage( - serverId ?? null, - { enabled: isTooltipOpen }, - ); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; - const handleTooltipOpenChange = useCallback( - (nextOpen: boolean) => { - setIsTooltipOpen(nextOpen); - if (nextOpen) { - void refreshProviderUsage().catch(() => {}); - } - }, - [refreshProviderUsage], - ); - const geometry = getMeterGeometry(showPercentage, glyphSize); // No usage yet: reserve the footprint with a track-only ring while a session is @@ -167,13 +144,7 @@ export function ContextWindowMeter({ typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; return ( - + ) : null} - diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 1cdfe83e6d..4902a3ae93 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; +import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; +import { + buildSettingsAddHostRoute, + buildSettingsRoute, + buildUsageRoute, +} from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; @@ -426,6 +430,25 @@ function SidebarHostPicker({ ); } +function SidebarUsageButton({ theme }: { theme: SidebarTheme }) { + const { t } = useTranslation(); + const isCompactLayout = useIsCompactFormFactor(); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const handlePress = useCallback(() => { + if (isCompactLayout) showMobileAgent(); + router.push(buildUsageRoute()); + }, [isCompactLayout, showMobileAgent]); + return ( + + ); +} + function IconTooltipContent({ label, shortcutKeys, @@ -489,6 +512,7 @@ function SidebarFooter({ icon={Import} theme={theme} /> + (); const snapshotIconComponents = new Map(); +/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */ +export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) { + return createElement(SvgXml, { xml: svg, width: size, height: size, color }); +} + function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent { const SvgProviderIcon: ProviderIconComponent = ({ size, color }) => - createElement(SvgXml, { - xml: iconSvg, - width: size, - height: size, - color, - }); + SvgIcon({ svg: iconSvg, size, color }); SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`; return SvgProviderIcon; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a8bdec9ede..c54a980ea7 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -48,6 +48,7 @@ import { type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; +import { UsageComposerPill } from "@/usage"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; @@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) { contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, model: agent?.model ?? null, - provider: agent?.provider ?? null, }; }; } @@ -284,8 +284,6 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, - serverId: string, - provider: string | null, pending: boolean, glyphSize: number, ): ReactElement | null { @@ -299,8 +297,6 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} - serverId={serverId} - provider={provider} pending={pending} glyphSize={glyphSize} /> @@ -2085,8 +2081,6 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, false, - serverId, - agentState.provider, contextWindowPending, contextWindowMeterGlyphSize, ), @@ -2094,15 +2088,18 @@ function ComposerContentImpl({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - serverId, - agentState.provider, contextWindowPending, contextWindowMeterGlyphSize, ], ); const beforeVoiceContent = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, hasAgent), - [contextWindowMeter, hasAgent], + () => ( + <> + {hasAgent ? : null} + {resolveContextWindowPlacement(contextWindowMeter, hasAgent)} + + ), + [agentId, contextWindowMeter, hasAgent, serverId], ); const hasGithubAttachment = useMemo( diff --git a/packages/app/src/provider-usage/copy.ts b/packages/app/src/provider-usage/copy.ts deleted file mode 100644 index 1dcc817ef5..0000000000 --- a/packages/app/src/provider-usage/copy.ts +++ /dev/null @@ -1,18 +0,0 @@ -// User-facing copy for the provider-usage surfaces, centralized so localization -// is a single-file change. INTEGRATION: move these into the i18n resources -// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the -// call sites once the feature is wired to data. Kept inline for now because the -// surfaces are not yet mounted and the locale files are being edited elsewhere. -export const providerUsageCopy = { - title: "Plan usage", - refresh: "Refresh", - refreshing: "Refreshing...", - loading: "Loading usage...", - empty: "No usage data", - errorTitle: "Unable to load usage", - hostUnavailable: "Connect to this host to see provider usage", - hostUpgradeRequired: "Update the host to see provider usage", - clientUnavailable: "Host connection is not ready", - retry: "Try again", - tooltipLoading: "Loading plan usage…", -} as const; diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx deleted file mode 100644 index 6cb6ff68dd..0000000000 --- a/packages/app/src/provider-usage/settings-section.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { RefreshCw } from "lucide-react-native"; -import { useMemo } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { Alert } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/components/settings/headings/settings-section"; -import { providerUsageCopy } from "./copy"; -import { ProviderUsageList } from "./list"; -import type { ProviderUsageView } from "./types"; - -export function ProviderUsageSettingsSection({ - view, - onRefresh, -}: { - view: ProviderUsageView; - onRefresh: () => void; -}) { - const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - - const refreshButton = useMemo( - () => ( - - ), - [busy, onRefresh], - ); - - return ( - - - - ); -} - -function ProviderUsageBody({ - view, - onRefresh, -}: { - view: ProviderUsageView; - onRefresh: () => void; -}) { - if (view.kind === "loading") { - return ( - - {providerUsageCopy.loading} - - ); - } - - if (view.kind === "error") { - return ( - - - - ); - } - - if (view.payload.providers.length === 0) { - return ( - - {providerUsageCopy.empty} - - ); - } - - return ; -} - -const styles = StyleSheet.create((theme) => ({ - emptyCard: { - padding: theme.spacing[4], - alignItems: "center", - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/provider-usage/tooltip-section.tsx b/packages/app/src/provider-usage/tooltip-section.tsx deleted file mode 100644 index 6a5a896301..0000000000 --- a/packages/app/src/provider-usage/tooltip-section.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { ProviderUsageCard } from "./card"; -import { providerUsageCopy } from "./copy"; -import type { ProviderUsage, ProviderUsageView } from "./types"; - -function matchProvider( - providers: ProviderUsage[], - activeProviderId: string | null | undefined, -): ProviderUsage | null { - if (!activeProviderId) return null; - const target = activeProviderId.toLowerCase(); - return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null; -} - -// Renders the active agent's provider usage inside the context-meter tooltip. -// Returns nothing when the active provider has no usage entry, so the meter's -// own context section stays the whole tooltip. -export function ProviderUsageTooltipSection({ - view, - activeProviderId, -}: { - view: ProviderUsageView; - activeProviderId: string | null | undefined; -}) { - if (view.kind === "loading") { - return ( - <> - - {providerUsageCopy.tooltipLoading} - - ); - } - - if (view.kind === "error") { - return ( - <> - - {view.message} - - ); - } - - const usage = matchProvider(view.payload.providers, activeProviderId); - if (!usage) return null; - - return ( - <> - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - // Same token the popover draws its own outline with, so the rule reads as the - // popover's edge. `border` is invisible here (equals the popover background). - backgroundColor: theme.colors.borderAccent, - marginVertical: theme.spacing[2], - // Cancel the tooltip content's horizontal padding so the rule spans edge to edge. - marginHorizontal: -theme.spacing[2], - }, - detail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, -})); diff --git a/packages/app/src/provider-usage/types.ts b/packages/app/src/provider-usage/types.ts deleted file mode 100644 index 52ff7c31f0..0000000000 --- a/packages/app/src/provider-usage/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageDetail, - ProviderUsageListResponseMessage, - ProviderUsageStatus, - ProviderUsageTone, - ProviderUsageWindow, -} from "@getpaseo/protocol/messages"; - -export type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageDetail, - ProviderUsageStatus, - ProviderUsageTone, - ProviderUsageWindow, -}; - -export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"]; -export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; - -export type ProviderUsageView = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean }; diff --git a/packages/app/src/provider-usage/use-provider-usage.ts b/packages/app/src/provider-usage/use-provider-usage.ts deleted file mode 100644 index ce133726c3..0000000000 --- a/packages/app/src/provider-usage/use-provider-usage.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { providerUsageCopy } from "./copy"; -import type { ProviderUsageListPayload, ProviderUsageView } from "./types"; - -export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000; - -type ProviderUsageClient = Pick; - -export function providerUsageQueryKey(serverId: string | null | undefined) { - return ["providerUsage", serverId ?? ""] as const; -} - -async function fetchProviderUsage(client: ProviderUsageClient): Promise { - return client.listProviderUsage(); -} - -interface UseProviderUsageOptions { - enabled?: boolean; -} - -export function useProviderUsage( - serverId: string | null | undefined, - options: UseProviderUsageOptions = {}, -): { - view: ProviderUsageView; - refresh: () => Promise; - canFetch: boolean; -} { - const queryClient = useQueryClient(); - const client = useHostRuntimeClient(serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const supportsProviderUsage = useSessionStore( - (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true, - ); - const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]); - const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage); - const enabled = Boolean((options.enabled ?? true) && canFetch); - - const queryFn = useCallback(async () => { - if (!client) { - throw new Error(providerUsageCopy.clientUnavailable); - } - return fetchProviderUsage(client); - }, [client]); - - const query = useQuery({ - queryKey, - queryFn, - enabled, - staleTime: PROVIDER_USAGE_STALE_TIME_MS, - refetchOnMount: true, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }); - - const refresh = useCallback(async () => { - if (!canFetch) return; - await queryClient.invalidateQueries({ queryKey }); - await queryClient.fetchQuery({ - queryKey, - queryFn, - staleTime: PROVIDER_USAGE_STALE_TIME_MS, - }); - }, [canFetch, queryClient, queryFn, queryKey]); - - const view = useMemo(() => { - if (!serverId || !client || !isConnected) { - return { kind: "error", message: providerUsageCopy.hostUnavailable }; - } - if (!supportsProviderUsage) { - return { kind: "error", message: providerUsageCopy.hostUpgradeRequired }; - } - if (query.data) { - return { - kind: "ready", - payload: query.data, - isRefreshing: query.isFetching, - }; - } - if (query.isError) { - return { - kind: "error", - message: query.error instanceof Error ? query.error.message : String(query.error), - }; - } - return { kind: "loading" }; - }, [ - client, - isConnected, - query.data, - query.error, - query.isError, - query.isFetching, - serverId, - supportsProviderUsage, - ]); - - return { view, refresh, canFetch }; -} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 5a9fd26b68..f7b27f820f 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -50,8 +50,7 @@ import { useHosts, } from "@/runtime/host-runtime"; import { ProvidersSection } from "@/screens/settings/providers-section"; -import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; -import { useProviderUsage } from "@/provider-usage/use-provider-usage"; +import { HostUsageSection } from "@/usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useSessionStore } from "@/stores/session-store"; @@ -343,10 +342,6 @@ export function HostProvidersPage({ serverId }: { serverId: string }) { export function HostUsagePage({ serverId }: { serverId: string }) { const host = useHostProfile(serverId); - const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId); - const handleRefresh = useCallback(() => { - void refreshProviderUsage(); - }, [refreshProviderUsage]); if (!host) { return ; @@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) { return ( - + ); } diff --git a/packages/app/src/provider-usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx similarity index 91% rename from packages/app/src/provider-usage/balance-bar.tsx rename to packages/app/src/usage/balance-bar.tsx index dbf18bea87..8bf3ad9d83 100644 --- a/packages/app/src/provider-usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -2,14 +2,14 @@ import { useMemo } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; -import type { ProviderUsageBalance, ProviderUsageTone } from "./types"; +import type { UsageBalance, UsageTone } from "./types"; interface ResolvedBalance { amountText: string; usedPct: number | null; } -function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance): ResolvedBalance { const { used, remaining, limit, unit } = balance; if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); @@ -26,7 +26,7 @@ function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { return { amountText: "—", usedPct: null }; } -function fillToneStyle(tone: ProviderUsageTone) { +function fillToneStyle(tone: UsageTone) { switch (tone) { case "ok": return styles.fillOk; @@ -39,7 +39,7 @@ function fillToneStyle(tone: ProviderUsageTone) { } } -export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) { +export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { const { amountText, usedPct } = resolveBalance(balance); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/provider-usage/card.tsx b/packages/app/src/usage/card.tsx similarity index 69% rename from packages/app/src/provider-usage/card.tsx rename to packages/app/src/usage/card.tsx index 10ef161fcc..4728b4a94a 100644 --- a/packages/app/src/provider-usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -1,51 +1,27 @@ import { useMemo } from "react"; import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; +import { StyleSheet } from "react-native-unistyles"; import { StatusBadge } from "@/components/ui/status-badge"; -import type { Theme } from "@/styles/theme"; -import { ProviderUsageBalanceBar } from "./balance-bar"; -import { formatAgo } from "./format"; -import type { ProviderUsage } from "./types"; -import { ProviderUsageWindowBar } from "./window-bar"; +import { UsageBalanceBar } from "./balance-bar"; +import { UsageSourceIcon } from "./source-icon"; +import type { UsageReport, UsageReportEntry } from "./types"; +import { UsageWindowBar } from "./window-bar"; -interface ProviderUsageIconProps { - iconKey: string; - size: number; - color?: string; +function statusText(report: UsageReport): string | null { + if (report.status === "available") return null; + return report.status === "error" ? "Error" : "Unavailable"; } -function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) { - const Icon = getProviderIcon(iconKey); - return ; -} - -const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon); - -const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -function statusText(usage: ProviderUsage): string | null { - if (usage.status === "available") return null; - return usage.status === "error" ? "Error" : "Unavailable"; -} - -function footerText(usage: ProviderUsage): string | null { - const updated = formatAgo(usage.fetchedAt); - const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); - return parts.length > 0 ? parts.join(" · ") : null; -} - -export function ProviderUsageCard({ - usage, +export function UsageCard({ + entry, compact = false, }: { - usage: ProviderUsage; + entry: UsageReportEntry; compact?: boolean; }) { + const usage = entry.report; const status = statusText(usage); - const footer = footerText(usage); + const footer = usage.account.label ?? null; const balances = usage.balances ?? []; const details = usage.details ?? []; @@ -65,9 +41,9 @@ export function ProviderUsageCard({ return ( - + - {usage.displayName} + {entry.sourceLabel} {usage.planLabel ? : null} @@ -88,10 +64,10 @@ export function ProviderUsageCard({ {usage.windows.length > 0 || balances.length > 0 ? ( {usage.windows.map((window) => ( - + ))} {balances.map((balance) => ( - + ))} ) : null} diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx new file mode 100644 index 0000000000..b21065c2fb --- /dev/null +++ b/packages/app/src/usage/composer-pill.tsx @@ -0,0 +1,86 @@ +import { useCallback } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { MenuTriggerState } from "@/components/ui/menu"; +import { + isToolbarLabelTriggerHighlighted, + toolbarLabelTriggerStyle, + toolbarLabelTriggerTextStyle, +} from "@/components/ui/toolbar-label-trigger"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import type { UsagePill } from "./model"; +import { useAgentUsage } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; + +function pillTriggerStyle(state: MenuTriggerState) { + return [toolbarLabelTriggerStyle(state), styles.trigger]; +} + +/** The agent's plan usage beside the composer controls. Opens the usage card. */ +export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) { + const { pill, entry, refresh } = useAgentUsage(serverId, agentId); + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) refresh(); + }, + [refresh], + ); + + if (!pill || !entry) return null; + + return ( + + + {(state) => ( + + )} + + + + + + + + ); +} + +function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) { + return ( + <> + + {pill.text ? ( + + {pill.text} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + height: 28, + flexShrink: 0, + }, + popover: { + padding: theme.spacing[3], + }, +})); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts new file mode 100644 index 0000000000..b8330f43d6 --- /dev/null +++ b/packages/app/src/usage/copy.ts @@ -0,0 +1,16 @@ +// User-facing copy for the usage surfaces, kept in one file so localization is a +// single-file change. +export const usageCopy = { + title: "Usage", + planUsage: "Plan usage", + refresh: "Refresh", + refreshing: "Refreshing...", + loading: "Loading usage...", + empty: "No usage data", + noHosts: "No connected hosts", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to this host to see usage", + hostUpgradeRequired: "Update the host to see usage", + clientUnavailable: "Host connection is not ready", + retry: "Try again", +} as const; diff --git a/packages/app/src/provider-usage/format.ts b/packages/app/src/usage/format.ts similarity index 63% rename from packages/app/src/provider-usage/format.ts rename to packages/app/src/usage/format.ts index d903818f48..ac8bffefd0 100644 --- a/packages/app/src/provider-usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,5 +1,5 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; -import type { ProviderUsageBalanceUnit } from "./types"; +import type { UsageBalanceUnit } from "./types"; export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); @@ -28,20 +28,7 @@ export function formatResetLabel(iso: string | null | undefined): string | null return rel === "now" ? "resetting now" : `resets ${rel}`; } -export function formatAgo(iso: string | null | undefined): string | null { - if (!iso) return null; - const diffMs = Date.now() - new Date(iso).getTime(); - if (!Number.isFinite(diffMs)) return null; - if (diffMs < 60_000) return "just now"; - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return `${diffDays}d ago`; - if (diffHours > 0) return `${diffHours}h ago`; - return `${diffMinutes}m ago`; -} - -export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string { +export function formatAmount(value: number, unit: UsageBalanceUnit): string { switch (unit) { case "usd": return `$${value.toFixed(2)}`; diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx new file mode 100644 index 0000000000..857a56e955 --- /dev/null +++ b/packages/app/src/usage/host-usage-section.tsx @@ -0,0 +1,11 @@ +import { usageCopy } from "./copy"; +import { useHostUsage } from "./queries"; +import { UsageSection } from "./usage-section"; + +/** A host's usage reports, for its settings page. */ +export function HostUsageSection({ serverId }: { serverId: string }) { + const { view, refresh } = useHostUsage(serverId); + return ( + + ); +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts new file mode 100644 index 0000000000..54f745a04d --- /dev/null +++ b/packages/app/src/usage/index.ts @@ -0,0 +1,3 @@ +export { UsageComposerPill } from "./composer-pill"; +export { HostUsageSection } from "./host-usage-section"; +export { UsageScreen } from "./usage-screen"; diff --git a/packages/app/src/provider-usage/list.tsx b/packages/app/src/usage/list.tsx similarity index 60% rename from packages/app/src/provider-usage/list.tsx rename to packages/app/src/usage/list.tsx index c9ff6742b2..24f8ccd67b 100644 --- a/packages/app/src/provider-usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -2,16 +2,16 @@ import { Fragment } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; -import { ProviderUsageCard } from "./card"; -import type { ProviderUsage } from "./types"; +import { UsageCard } from "./card"; +import type { UsageReportEntry } from "./types"; -export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) { +export function UsageList({ reports }: { reports: UsageReportEntry[] }) { return ( - {providers.map((usage, index) => ( - + {reports.map((entry, index) => ( + {index > 0 ? : null} - + ))} diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts new file mode 100644 index 0000000000..ad73ea761b --- /dev/null +++ b/packages/app/src/usage/model.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + groupUsageByHost, + headlineWindow, + resolveUsagePill, + resolveUsageView, + type UsageQueryState, +} from "./model"; +import type { UsageReportEntry, UsageWindow } from "./types"; + +function entry(input: { + windows?: UsageWindow[]; + planLabel?: string; + icon?: string; + sourceId?: string; +}): UsageReportEntry { + return { + sourceId: input.sourceId ?? "fixture", + sourceLabel: "Fixture source", + ...(input.icon ? { icon: input.icon } : {}), + report: { + account: { key: "account-1" }, + status: "available", + ...(input.planLabel ? { planLabel: input.planLabel } : {}), + windows: input.windows ?? [], + }, + }; +} + +function ready(data: UsageReportEntry[]): UsageQueryState { + return { data, error: null, isFetching: false }; +} + +describe("resolveUsagePill", () => { + it("shows the source icon and the headline window percent", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + icon: "", + windows: [ + { id: "session", label: "Session", usedPct: 12 }, + { id: "weekly", label: "Weekly", usedPct: 64.6, headline: true }, + ], + }), + }); + + expect(pill).toEqual({ icon: "", sourceLabel: "Fixture source", text: "65%" }); + }); + + it("derives the headline percent from remaining percent", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }], + }), + }); + + expect(pill?.text).toBe("70%"); + }); + + it("shows the plan label when no window is the headline", () => { + const pill = resolveUsagePill({ + supportsUsage: true, + entry: entry({ + planLabel: "Pro", + windows: [{ id: "session", label: "Session", usedPct: 40 }], + }), + }); + + expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" }); + }); + + it("shows only the icon with neither a headline window nor a plan label", () => { + const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) }); + + expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null }); + }); + + it("is hidden when the agent has no usage report", () => { + expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull(); + }); + + it("is hidden on a host without usage sources, whatever data is cached", () => { + const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] }); + + expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull(); + }); +}); + +describe("headlineWindow", () => { + it("does not fall back to the first window", () => { + expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe( + null, + ); + }); +}); + +describe("resolveUsageView", () => { + it("asks for a host update when the host lacks usage sources", () => { + expect( + resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }), + ).toEqual({ kind: "unavailable", message: "Update the host to see usage" }); + }); + + it("asks for a connection before anything else", () => { + expect( + resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }), + ).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" }); + }); + + it("moves from loading to ready to error", () => { + const base = { isConnected: true, supportsUsage: true }; + expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); + expect( + resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }), + ).toEqual({ kind: "ready", reports: [], isRefreshing: true }); + expect( + resolveUsageView({ + ...base, + query: { data: undefined, error: new Error("boom"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "boom" }); + }); +}); + +describe("groupUsageByHost", () => { + it("groups reports under each connected host in host order", () => { + const first = entry({ sourceId: "one" }); + const second = entry({ sourceId: "two" }); + const groups = groupUsageByHost( + [ + { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true }, + { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, + { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true }, + ], + new Map([ + ["a", ready([first])], + ["b", ready([second, first])], + ]), + ); + + expect(groups).toEqual([ + { + serverId: "b", + label: "Beta", + view: { kind: "ready", reports: [second, first], isRefreshing: false }, + }, + { + serverId: "a", + label: "Alpha", + view: { kind: "ready", reports: [first], isRefreshing: false }, + }, + ]); + }); + + it("shows the update message in an old host's group", () => { + const groups = groupUsageByHost( + [ + { serverId: "new", label: "New", isConnected: true, supportsUsage: true }, + { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, + ], + new Map([["new", ready([])]]), + ); + + expect(groups.map((group) => [group.label, group.view])).toEqual([ + ["New", { kind: "ready", reports: [], isRefreshing: false }], + ["Old", { kind: "unavailable", message: "Update the host to see usage" }], + ]); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts new file mode 100644 index 0000000000..ef74cdcdae --- /dev/null +++ b/packages/app/src/usage/model.ts @@ -0,0 +1,95 @@ +import { usageCopy } from "./copy"; +import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; + +export function usedPercent(window: UsageWindow): number | null { + if (window.usedPct != null) return window.usedPct; + if (window.remainingPct != null) return 100 - window.remainingPct; + return null; +} + +/** The window the source marked as its headline. Sources own that choice; there is no fallback. */ +export function headlineWindow(report: UsageReport): UsageWindow | null { + return report.windows.find((window) => window.headline === true) ?? null; +} + +export interface UsagePill { + icon: string | null; + sourceLabel: string; + /** Headline percent, else the plan label, else nothing beside the icon. */ + text: string | null; +} + +export function resolveUsagePill(input: { + supportsUsage: boolean; + entry: UsageReportEntry | null | undefined; +}): UsagePill | null { + const { supportsUsage, entry } = input; + if (!supportsUsage || !entry) return null; + const window = headlineWindow(entry.report); + const percent = window ? usedPercent(window) : null; + return { + icon: entry.icon ?? null, + sourceLabel: entry.sourceLabel, + text: + percent != null + ? `${Math.round(Math.max(0, Math.min(100, percent)))}%` + : (entry.report.planLabel ?? null), + }; +} + +export interface UsageQueryState { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +} + +export function resolveUsageView(input: { + isConnected: boolean; + supportsUsage: boolean; + query: UsageQueryState | undefined; +}): UsageView { + const { isConnected, supportsUsage, query } = input; + if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable }; + if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired }; + if (query?.data) { + return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; + } + if (query?.error) { + return { + kind: "error", + message: query.error instanceof Error ? query.error.message : String(query.error), + }; + } + return { kind: "loading" }; +} + +export interface UsageHost { + serverId: string; + label: string; + isConnected: boolean; + supportsUsage: boolean; +} + +export interface UsageHostGroup { + serverId: string; + label: string; + view: UsageView; +} + +/** One group per connected host, in host order. */ +export function groupUsageByHost( + hosts: readonly UsageHost[], + queries: ReadonlyMap, +): UsageHostGroup[] { + return hosts + .filter((host) => host.isConnected) + .map((host) => ({ + serverId: host.serverId, + label: host.label, + view: resolveUsageView({ + isConnected: true, + supportsUsage: host.supportsUsage, + query: queries.get(host.serverId), + }), + })); +} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts new file mode 100644 index 0000000000..8937e37f9b --- /dev/null +++ b/packages/app/src/usage/queries.ts @@ -0,0 +1,178 @@ +import { useCallback, useMemo } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useShallow } from "zustand/shallow"; +import { useFetchQueries, useFetchQuery } from "@/data/query"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHostRuntimeIsConnected, + useHosts, +} from "@/runtime/host-runtime"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; +import { usageCopy } from "./copy"; +import { + groupUsageByHost, + resolveUsagePill, + resolveUsageView, + type UsageHostGroup, + type UsagePill, + type UsageQueryState, +} from "./model"; +import type { UsageReportEntry, UsageView } from "./types"; + +// The daemon caches each report for five minutes, so re-reading it is cheap. Only +// an explicit refresh passes `forceRefresh` and reaches the source's API. +const REPORTS_STALE_TIME_MS = 60_000; + +function usageReportsQueryKey(serverId: string) { + return ["usage", "reports", serverId] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) { + return ["usage", "agent", serverId, agentId, model] as const; +} + +function requireClient(serverId: string) { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) throw new Error(usageCopy.clientUnavailable); + return client; +} + +async function listReports(serverId: string, forceRefresh = false): Promise { + return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports; +} + +async function getAgentReport( + serverId: string, + agentId: string, + forceRefresh = false, +): Promise { + return (await requireClient(serverId).getAgentUsageReport({ agentId, forceRefresh })).entry; +} + +function supportsUsage(session: SessionState | undefined): boolean { + return session?.serverInfo?.features?.usageSources === true; +} + +async function refreshReports(queryClient: QueryClient, serverId: string): Promise { + await queryClient.fetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: () => listReports(serverId, true), + staleTime: 0, + }); +} + +function toQueryState(query: { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +}): UsageQueryState { + return { data: query.data, error: query.error, isFetching: query.isFetching }; +} + +/** Usage reports for one host, as shown on its settings page. */ +export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } { + const queryClient = useQueryClient(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + const query = useFetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: () => listReports(serverId), + enabled: isConnected && isSupported, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + const refresh = useCallback(() => { + void refreshReports(queryClient, serverId).catch(() => undefined); + }, [queryClient, serverId]); + const view = resolveUsageView({ + isConnected, + supportsUsage: isSupported, + query: toQueryState(query), + }); + return { view, refresh }; +} + +/** Usage reports for every connected host, grouped by host. */ +export function useUsageByHost(): { + groups: UsageHostGroup[]; + refresh: (serverId: string) => void; +} { + const queryClient = useQueryClient(); + const hosts = useHosts(); + const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); + const supportedServerIds = useSessionStore( + useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))), + ); + const usageHosts = useMemo( + () => + hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + isConnected: connectionStatuses.get(host.serverId) === "online", + supportsUsage: supportedServerIds.includes(host.serverId), + })), + [connectionStatuses, hosts, supportedServerIds], + ); + const results = useFetchQueries( + usageHosts.map((host) => ({ + queryKey: usageReportsQueryKey(host.serverId), + queryFn: () => listReports(host.serverId), + enabled: host.isConnected && host.supportsUsage, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + })), + ); + const groups = groupUsageByHost( + usageHosts, + new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])), + ); + const refresh = useCallback( + (serverId: string) => { + void refreshReports(queryClient, serverId).catch(() => undefined); + }, + [queryClient], + ); + return { groups, refresh }; +} + +/** + * The usage report for the account an agent is spending. The daemon resolves the + * account at fetch time, so the query refetches when the model changes (a new key) + * and when a turn completes (the query is paused while the agent runs, and its + * data is always stale, so resuming refetches). + */ +export function useAgentUsage( + serverId: string, + agentId: string, +): { pill: UsagePill | null; entry: UsageReportEntry | null; refresh: () => void } { + const queryClient = useQueryClient(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + const { model, isRunning } = useSessionStore( + useShallow((state) => { + const agent = state.sessions[serverId]?.agents?.get(agentId); + return { model: agent?.model ?? null, isRunning: agent?.status === "running" }; + }), + ); + const queryKey = agentUsageQueryKey(serverId, agentId, model); + const query = useFetchQuery({ + queryKey, + queryFn: () => getAgentReport(serverId, agentId), + enabled: isConnected && isSupported && !isRunning, + dataShape: "value", + staleTimeMs: 0, + }); + const refresh = useCallback(() => { + void queryClient + .fetchQuery({ + queryKey: agentUsageQueryKey(serverId, agentId, model), + queryFn: () => getAgentReport(serverId, agentId, true), + staleTime: 0, + }) + .catch(() => undefined); + }, [agentId, model, queryClient, serverId]); + const entry = isSupported ? (query.data ?? null) : null; + return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry, refresh }; +} diff --git a/packages/app/src/usage/source-icon.tsx b/packages/app/src/usage/source-icon.tsx new file mode 100644 index 0000000000..779f42f841 --- /dev/null +++ b/packages/app/src/usage/source-icon.tsx @@ -0,0 +1,26 @@ +import { Gauge } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { SvgIcon } from "@/components/provider-icons"; +import type { Theme } from "@/styles/theme"; + +function UsageSourceIconBase({ + svg, + size, + color = "", +}: { + svg: string | null; + size: number; + color?: string; +}) { + if (!svg) return ; + return ; +} + +const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase); + +const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The source's catalog SVG, or a generic gauge when the source ships none. */ +export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) { + return ; +} diff --git a/packages/app/src/provider-usage/tone.ts b/packages/app/src/usage/tone.ts similarity index 74% rename from packages/app/src/provider-usage/tone.ts rename to packages/app/src/usage/tone.ts index 5cf6f68bab..4727853c2f 100644 --- a/packages/app/src/provider-usage/tone.ts +++ b/packages/app/src/usage/tone.ts @@ -1,6 +1,6 @@ -import type { ProviderUsageTone } from "./types"; +import type { UsageTone } from "./types"; -export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone { +export function deriveTone(usedPct: number | null | undefined): UsageTone { if (usedPct == null) return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts new file mode 100644 index 0000000000..2e2485b2d5 --- /dev/null +++ b/packages/app/src/usage/types.ts @@ -0,0 +1,15 @@ +import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages"; + +export type { UsageReportEntry }; +export type UsageReport = UsageReportEntry["report"]; +export type UsageTone = ProviderUsageTone; +export type UsageWindow = UsageReport["windows"][number]; +export type UsageBalance = NonNullable[number]; +export type UsageBalanceUnit = UsageBalance["unit"]; + +/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ +export type UsageView = + | { kind: "unavailable"; message: string } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean }; diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx new file mode 100644 index 0000000000..8fc4c429db --- /dev/null +++ b/packages/app/src/usage/usage-screen.tsx @@ -0,0 +1,70 @@ +import { useCallback } from "react"; +import { ScrollView, View } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { usageCopy } from "./copy"; +import type { UsageHostGroup } from "./model"; +import { useUsageByHost } from "./queries"; +import { UsageMessage, UsageSection } from "./usage-section"; + +export function UsageScreen() { + const isFocused = useIsFocused(); + return ( + + + {isFocused ? : null} + + ); +} + +function UsageScreenContent() { + const insets = useSafeAreaInsets(); + const { groups, refresh } = useUsageByHost(); + return ( + + + {groups.length === 0 ? : null} + {groups.map((group) => ( + + ))} + + + ); +} + +function HostUsageGroup({ + group, + onRefresh, +}: { + group: UsageHostGroup; + onRefresh: (serverId: string) => void; +}) { + const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + scroll: { + flex: 1, + }, + content: { + padding: theme.spacing[4], + paddingTop: theme.spacing[6], + width: "100%", + maxWidth: 720, + alignSelf: "center", + }, +})); diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx new file mode 100644 index 0000000000..37d0105261 --- /dev/null +++ b/packages/app/src/usage/usage-section.tsx @@ -0,0 +1,93 @@ +import { RefreshCw } from "lucide-react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; +import { usageCopy } from "./copy"; +import { UsageList } from "./list"; +import type { UsageView } from "./types"; + +export function UsageSection({ + title, + view, + onRefresh, + testID, +}: { + title: string; + view: UsageView; + onRefresh: () => void; + testID?: string; +}) { + const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); + + const refreshButton = useMemo( + () => + view.kind === "unavailable" ? null : ( + + ), + [busy, onRefresh, view.kind], + ); + + return ( + + + + ); +} + +function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) { + if (view.kind === "unavailable") { + return ; + } + + if (view.kind === "loading") { + return ; + } + + if (view.kind === "error") { + return ( + + + + ); + } + + if (view.reports.length === 0) { + return ; + } + + return ; +} + +export function UsageMessage({ text }: { text: string }) { + return ( + + {text} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/provider-usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx similarity index 85% rename from packages/app/src/provider-usage/window-bar.tsx rename to packages/app/src/usage/window-bar.tsx index 0bd198521b..9627cb1571 100644 --- a/packages/app/src/provider-usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -2,16 +2,11 @@ import { useMemo } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatPct, formatResetLabel } from "./format"; +import { usedPercent } from "./model"; import { deriveTone } from "./tone"; -import type { ProviderUsageTone, ProviderUsageWindow } from "./types"; +import type { UsageTone, UsageWindow } from "./types"; -function resolveUsedPct(window: ProviderUsageWindow): number | null { - if (window.usedPct != null) return window.usedPct; - if (window.remainingPct != null) return 100 - window.remainingPct; - return null; -} - -function fillToneStyle(tone: ProviderUsageTone) { +function fillToneStyle(tone: UsageTone) { switch (tone) { case "ok": return styles.fillOk; @@ -24,8 +19,8 @@ function fillToneStyle(tone: ProviderUsageTone) { } } -export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) { - const usedPct = resolveUsedPct(window); +export function UsageWindowBar({ window }: { window: UsageWindow }) { + const usedPct = usedPercent(window); const tone = window.tone ?? deriveTone(usedPct); const fillWidth = clampPct(usedPct ?? 0); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index c13d6f25f9..1dc3ff48bb 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,6 +428,10 @@ export function buildSchedulesRoute() { return "/schedules" as const; } +export function buildUsageRoute() { + return "/usage" as const; +} + export function buildOpenProjectRoute() { return "/open-project" as const; }