mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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}
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
+5
-10
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user