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.
This commit is contained in:
Mohamed Boudra
2026-09-26 20:53:05 +02:00
parent bfa26537c9
commit 9d7286ce0c
37 changed files with 1313 additions and 875 deletions
@@ -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();
});
});
@@ -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();
}
});
});
@@ -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: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
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();
}
});
});
@@ -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);
});
});
@@ -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 = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
function report(input: {
sourceId: string;
sourceLabel: string;
report: Partial<UsageReportEntry["report"]>;
}): 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);
});
});
@@ -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<void>;
}
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<string, unknown> | 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<string, unknown>;
}
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<string, unknown>;
};
};
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<ProviderUsageFixture> {
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<void>((resolve) => {
waiters.push({ count, resolve });
});
},
};
}
@@ -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<void>;
waitForAgentRequests(count: number): Promise<void>;
}
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<UsageReportEntry | null>;
/** 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<string, unknown> | 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<string, unknown>;
}
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
} | 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<T>(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<void> {
if (count >= target) return Promise.resolve();
return new Promise((resolve) => waiters.push({ count: target, resolve }));
},
};
}
export async function installUsageReportsFixture(
page: Page,
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
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),
};
}
+2
View File
@@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
pathname === "/usage" ||
routeHasKnownHost);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
@@ -892,6 +893,7 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="usage" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { UsageScreen } from "@/usage";
export default function UsageRoute() {
return (
<HostRouteBootstrapBoundary>
<UsageScreen />
</HostRouteBootstrapBoundary>
);
}
@@ -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 (
<Tooltip
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
@@ -233,7 +204,6 @@ export function ContextWindowMeter({
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View>
</TooltipContent>
</Tooltip>
+26 -2
View File
@@ -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 (
<FooterIconButton
onPress={handlePress}
testID="sidebar-usage"
label={t("settings.hostSections.usage")}
icon={Gauge}
theme={theme}
/>
);
}
function IconTooltipContent({
label,
shortcutKeys,
@@ -489,6 +512,7 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
<SidebarUsageButton theme={theme} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
/** 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;
}
+8 -11
View File
@@ -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 ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
</>
),
[agentId, contextWindowMeter, hasAgent, serverId],
);
const hasGithubAttachment = useMemo(
-18
View File
@@ -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;
@@ -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(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={providerUsageCopy.refresh}
>
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
</Button>
),
[busy, onRefresh],
);
return (
<SettingsSection
title={providerUsageCopy.title}
testID="provider-usage-card"
trailing={refreshButton}
>
<ProviderUsageBody view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function ProviderUsageBody({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
if (view.kind === "loading") {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
</View>
);
}
if (view.kind === "error") {
return (
<Alert
size="sm"
variant="error"
title={providerUsageCopy.errorTitle}
description={view.message}
>
<Button variant="outline" size="sm" onPress={onRefresh}>
{providerUsageCopy.retry}
</Button>
</Alert>
);
}
if (view.payload.providers.length === 0) {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
</View>
);
}
return <ProviderUsageList providers={view.payload.providers} />;
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -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 (
<>
<View style={styles.divider} />
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
</>
);
}
if (view.kind === "error") {
return (
<>
<View style={styles.divider} />
<Text style={styles.error}>{view.message}</Text>
</>
);
}
const usage = matchProvider(view.payload.providers, activeProviderId);
if (!usage) return null;
return (
<>
<View style={styles.divider} />
<ProviderUsageCard usage={usage} compact />
</>
);
}
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,
},
}));
-26
View File
@@ -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 };
@@ -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<DaemonClient, "listProviderUsage">;
export function providerUsageQueryKey(serverId: string | null | undefined) {
return ["providerUsage", serverId ?? ""] as const;
}
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
return client.listProviderUsage();
}
interface UseProviderUsageOptions {
enabled?: boolean;
}
export function useProviderUsage(
serverId: string | null | undefined,
options: UseProviderUsageOptions = {},
): {
view: ProviderUsageView;
refresh: () => Promise<void>;
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<ProviderUsageView>(() => {
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 };
}
@@ -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 <HostNotFound />;
@@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) {
return (
<View>
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
<HostUsageSection serverId={serverId} />
</View>
);
}
@@ -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);
@@ -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 <Icon size={size} color={color} />;
}
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 (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
@@ -88,10 +64,10 @@ export function ProviderUsageCard({
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<ProviderUsageWindowBar key={window.id} window={window} />
<UsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
<UsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
+86
View File
@@ -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 (
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
style={pillTriggerStyle}
accessibilityRole="button"
accessibilityLabel={`${pill.sourceLabel} ${usageCopy.planUsage}`}
testID="usage-composer-pill"
>
{(state) => (
<UsagePillContent pill={pill} highlighted={isToolbarLabelTriggerHighlighted(state)} />
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={300}
testID="usage-composer-popover"
sheetTitle={usageCopy.planUsage}
>
<View style={styles.popover}>
<UsageCard entry={entry} compact />
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
return (
<>
<UsageSourceIcon svg={pill.icon} size={14} />
{pill.text ? (
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
{pill.text}
</Text>
) : null}
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexShrink: 0,
},
popover: {
padding: theme.spacing[3],
},
}));
+16
View File
@@ -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;
@@ -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)}`;
@@ -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 (
<UsageSection title={usageCopy.planUsage} view={view} onRefresh={refresh} testID="usage-card" />
);
}
+3
View File
@@ -0,0 +1,3 @@
export { UsageComposerPill } from "./composer-pill";
export { HostUsageSection } from "./host-usage-section";
export { UsageScreen } from "./usage-screen";
@@ -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 (
<View style={settingsStyles.card}>
{providers.map((usage, index) => (
<Fragment key={usage.providerId}>
{reports.map((entry, index) => (
<Fragment key={`${entry.sourceId}:${entry.report.account.key}`}>
{index > 0 ? <View style={styles.divider} /> : null}
<ProviderUsageCard usage={usage} />
<UsageCard entry={entry} />
</Fragment>
))}
</View>
+170
View File
@@ -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: "<svg/>",
windows: [
{ id: "session", label: "Session", usedPct: 12 },
{ id: "weekly", label: "Weekly", usedPct: 64.6, headline: true },
],
}),
});
expect(pill).toEqual({ icon: "<svg/>", 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" }],
]);
});
});
+95
View File
@@ -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<string, UsageQueryState>,
): 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),
}),
}));
}
+178
View File
@@ -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<UsageReportEntry[]> {
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
}
async function getAgentReport(
serverId: string,
agentId: string,
forceRefresh = false,
): Promise<UsageReportEntry | null> {
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<void> {
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<UsageReportEntry[]>(
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 };
}
+26
View File
@@ -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 <Gauge size={size} color={color} />;
return <SvgIcon svg={svg} size={size} color={color} />;
}
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 <ThemedUsageSourceIcon svg={svg} size={size} uniProps={mutedIconColor} />;
}
@@ -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";
+15
View File
@@ -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<UsageReport["balances"]>[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 };
+70
View File
@@ -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 (
<View style={styles.container}>
<MenuHeader title={usageCopy.title} />
{isFocused ? <UsageScreenContent /> : null}
</View>
);
}
function UsageScreenContent() {
const insets = useSafeAreaInsets();
const { groups, refresh } = useUsageByHost();
return (
<ScrollView style={styles.scroll} testID="usage-screen">
<View style={[styles.content, { paddingBottom: insets.bottom }]}>
{groups.length === 0 ? <UsageMessage text={usageCopy.noHosts} /> : null}
{groups.map((group) => (
<HostUsageGroup key={group.serverId} group={group} onRefresh={refresh} />
))}
</View>
</ScrollView>
);
}
function HostUsageGroup({
group,
onRefresh,
}: {
group: UsageHostGroup;
onRefresh: (serverId: string) => void;
}) {
const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
return (
<UsageSection
title={group.label}
view={group.view}
onRefresh={handleRefresh}
testID={`usage-host-${group.serverId}`}
/>
);
}
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",
},
}));
+93
View File
@@ -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 : (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={usageCopy.refresh}
>
{busy ? usageCopy.refreshing : usageCopy.refresh}
</Button>
),
[busy, onRefresh, view.kind],
);
return (
<SettingsSection title={title} testID={testID} trailing={refreshButton}>
<UsageBody view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) {
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
if (view.kind === "loading") {
return <UsageMessage text={usageCopy.loading} />;
}
if (view.kind === "error") {
return (
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{usageCopy.retry}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={usageCopy.empty} />;
}
return <UsageList reports={view.reports} />;
}
export function UsageMessage({ text }: { text: string }) {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -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);
+4
View File
@@ -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;
}