mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Open Usage as a modal over the current screen (#6216)
* Open Usage as a modal over the current screen The sidebar Usage icon and summary now open Usage in the adaptive modal (a dialog on wide layouts, a bottom sheet on compact ones) instead of navigating to the /usage screen, so closing it leaves you where you were. The /usage route and UsageScreen are removed. * Reset Usage modal cards when the host changes; keep scroll test body linear
This commit is contained in:
+2
-2
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+1
-1
@@ -488,7 +488,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
|
||||
+25
-18
@@ -18,13 +18,15 @@ import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
@@ -98,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -134,14 +136,13 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await openUsageFromItem(page);
|
||||
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(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
@@ -176,7 +177,7 @@ test.describe("usage screen", () => {
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
// The sidebar summary and the modal each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
(request) =>
|
||||
@@ -187,7 +188,7 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -224,7 +225,9 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
@@ -238,8 +241,8 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
||||
// Without reports the footer has no Usage item; its Usage icon opens Usage.
|
||||
await openUsageFromIcon(page);
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
@@ -250,7 +253,7 @@ test.describe("usage screen", () => {
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
|
||||
test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(420_000);
|
||||
@@ -273,7 +276,7 @@ test.describe("usage screen", () => {
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
@@ -282,7 +285,7 @@ test.describe("usage screen", () => {
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
@@ -292,6 +295,7 @@ test.describe("usage screen", () => {
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
@@ -320,7 +324,7 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -366,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
@@ -413,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||
await expectCodexReportedWindows(page, shape);
|
||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||
@@ -439,7 +444,9 @@ for (const theme of ["light", "dark"] as const) {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
if (size === "compact") await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const settings = page.getByTestId("usage-options-menu");
|
||||
@@ -501,7 +508,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
]);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, [
|
||||
"Codex: Usage API returned 500. Try again.",
|
||||
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
import {
|
||||
openUsageFromIcon,
|
||||
refreshAllUsage,
|
||||
showUsageAs,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
@@ -98,7 +102,8 @@ test.describe("usage settings", () => {
|
||||
// The shared percentages setting applies to the host section.
|
||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await showUsageAs(page, "remaining");
|
||||
await page.goto(hostUsageUrl);
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
|
||||
@@ -2,6 +2,11 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
@@ -14,28 +19,29 @@ import {
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsageSheet,
|
||||
closeUsage,
|
||||
closeUsageFromBackdrop,
|
||||
closeUsageOptions,
|
||||
openUsageSheetFromIcon,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsage,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
usageModal,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -75,49 +81,96 @@ test.describe("Usage item", () => {
|
||||
await expectNoUsageItem(page);
|
||||
|
||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||
await openUsageSheetFromIcon(page);
|
||||
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await closeUsageSheet(page);
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await usageItem(page).click();
|
||||
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await openUsageFromItem(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
await closeUsageSheet(page);
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
});
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
|
||||
test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
const repo = await createTempGitRepo("usage-modal-");
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
await page.setViewportSize(WIDE);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
|
||||
await test.step("the summary opens a dialog over the workspace", async () => {
|
||||
await openUsageFromItem(page);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Refresh all", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await qaScreenshot(page, "desktop-usage-dialog");
|
||||
});
|
||||
|
||||
await test.step("Close returns to the same workspace", async () => {
|
||||
await closeUsage(page);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await waitForTabBar(page);
|
||||
});
|
||||
|
||||
await test.step("the icon opens it again, and Escape closes it", async () => {
|
||||
await openUsageFromIcon(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
});
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const layouts = {
|
||||
desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(viewport);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await showSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await openUsageFromItem(page);
|
||||
const content = usageModal(page);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsage(page, 5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsage(page, -5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -146,8 +199,7 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
@@ -172,17 +224,13 @@ test.describe("Usage item", () => {
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await test.step("remaining flips the Usage item and the Usage modal", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
@@ -194,23 +242,15 @@ test.describe("Usage item", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await closeUsage(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await openUsageFromItem(page);
|
||||
const sheet = usageModal(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
@@ -231,25 +271,20 @@ test.describe("Usage item", () => {
|
||||
await closeUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await waitForSettledPosition(sheet);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
// The Usage sheet reserves the top 20% as its backdrop.
|
||||
// The sheet leaves the screen above it as its backdrop.
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await closeUsageFromBackdrop(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageFromItem(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -260,13 +295,15 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
const workspaceUrl = page.url();
|
||||
await closeUsage(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await page.goto(workspaceUrl);
|
||||
await openUsageFromItem(page);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -275,6 +312,7 @@ test.describe("Usage item", () => {
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoUsageItem(page);
|
||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
||||
@@ -282,6 +320,7 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||
await closeUsage(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
@@ -325,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
||||
@@ -340,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
await qaScreenshot(page, "usage-summary-off");
|
||||
});
|
||||
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
@@ -355,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
await qaScreenshot(page, "usage-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
@@ -363,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
@@ -391,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
||||
@@ -400,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-screen");
|
||||
await qaScreenshot(page, "released-host-usage-modal");
|
||||
});
|
||||
|
||||
@@ -58,10 +58,6 @@ export default function contribute(server) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUsage(page: Page) {
|
||||
await page.goto("/usage");
|
||||
}
|
||||
|
||||
export async function refreshLoginUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
/** Real usage-source plugin; its long report makes the Usage modal scroll. */
|
||||
export async function installTallUsageSource() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
@@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
|
||||
/** The compact usage sheet the Usage item opens. */
|
||||
export function usageSheet(page: Page): Locator {
|
||||
return visible(page, "usage-expanded");
|
||||
}
|
||||
|
||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
|
||||
export function usageModal(page: Page): Locator {
|
||||
return visible(page, "usage-modal-body");
|
||||
}
|
||||
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
@@ -189,23 +185,45 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
|
||||
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
/**
|
||||
* The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the
|
||||
* sidebar drawer has to be open.
|
||||
*/
|
||||
export async function openUsageFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Both footer entry points open Usage over the current screen on a phone. */
|
||||
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click();
|
||||
await expect(usageSheet(page)).toBeVisible();
|
||||
/** The footer's Usage item, its summary of pinned windows. */
|
||||
export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await usageItem(page).click();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeUsageSheet(page: Page): Promise<void> {
|
||||
const sheet = usageSheet(page);
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!body || !viewport) throw new Error("Usage must be open before scrolling it.");
|
||||
// The middle of the part of the body on screen; the body itself runs past the viewport.
|
||||
const top = Math.max(body.y, 0);
|
||||
const bottom = Math.min(body.y + body.height, viewport.height);
|
||||
await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2);
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
/** The modal's own Close button. */
|
||||
export async function closeUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** A tap on the backdrop above the bottom sheet. */
|
||||
export async function closeUsageFromBackdrop(page: Page): Promise<void> {
|
||||
const sheet = usageModal(page);
|
||||
await waitForSettledPosition(sheet);
|
||||
const bounds = await sheet.boundingBox();
|
||||
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
|
||||
const bounds = await visible(page, "usage-modal").boundingBox();
|
||||
if (!bounds) throw new Error("Usage must be open before closing it.");
|
||||
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
@@ -214,7 +232,7 @@ function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
|
||||
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
|
||||
/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */
|
||||
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await summaryInSidebarSwitch(page).click();
|
||||
@@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
|
||||
export async function leaveUsageScreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
@@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/usage" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
@@ -896,7 +895,6 @@ 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]" />
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { UsageScreen } from "@/usage";
|
||||
|
||||
export default function UsageRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<UsageScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model";
|
||||
|
||||
/**
|
||||
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
|
||||
* compact ones. Plugin items and the built-in Usage item open it the same way:
|
||||
* compact ones. Plugin items open it this way:
|
||||
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
|
||||
* and `SidebarPopoverSurface` for the body.
|
||||
*/
|
||||
|
||||
@@ -65,8 +65,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
/**
|
||||
* One host's usage and the title-row controls that go with it, for the Usage screen and the
|
||||
* compact usage sheet.
|
||||
* One host's usage and the title-row controls that go with it, for the Usage modal.
|
||||
*/
|
||||
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
view: UsageView;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
resolveUsageHostId,
|
||||
resolveUsageScreenHostId,
|
||||
resolveUsageModalHostId,
|
||||
type UsageHost,
|
||||
type UsageHostChoice,
|
||||
} from "./model";
|
||||
@@ -35,7 +35,7 @@ export function useUsageHostId(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick
|
||||
* The host the Usage modal shows, and the hosts to pick from. A pick
|
||||
* is saved on the device, so the sidebar Usage row and later visits show the same host.
|
||||
*/
|
||||
export function useUsageHostSelection(): {
|
||||
@@ -55,5 +55,5 @@ export function useUsageHostSelection(): {
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select };
|
||||
return { serverId: resolveUsageModalHostId(choice), connectedHosts, select };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AgentUsage } from "./agent-usage";
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { useHostReportsUsage } from "./queries";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
replaceReport,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageHostId,
|
||||
resolveUsageScreenHostId,
|
||||
resolveUsageModalHostId,
|
||||
resolveUsageView,
|
||||
settleReports,
|
||||
upsertReport,
|
||||
@@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUsageScreenHostId", () => {
|
||||
describe("resolveUsageModalHostId", () => {
|
||||
it("shows the picked host while it stays connected, even one that cannot report usage", () => {
|
||||
expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe(
|
||||
expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe(
|
||||
"old",
|
||||
);
|
||||
expect(
|
||||
resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }),
|
||||
).toBe("b");
|
||||
expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe(
|
||||
"b",
|
||||
);
|
||||
});
|
||||
|
||||
it("otherwise shows the host the sidebar row reads", () => {
|
||||
for (const pickedServerId of [null, "a", "offline"]) {
|
||||
for (const activeServerId of ["b", null, "old", "offline"]) {
|
||||
const choice = { pickedServerId, activeServerId, hosts };
|
||||
expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice));
|
||||
expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the first connected host when none reports usage, so the screen says to update it", () => {
|
||||
it("shows the first connected host when none reports usage, so the modal says to update it", () => {
|
||||
expect(
|
||||
resolveUsageScreenHostId({
|
||||
resolveUsageModalHostId({
|
||||
pickedServerId: null,
|
||||
activeServerId: null,
|
||||
hosts: hosts.slice(0, 2),
|
||||
}),
|
||||
).toBe("old");
|
||||
expect(
|
||||
resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
|
||||
resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,11 +176,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The host the Usage screen shows: the picked host while it is connected, even one that cannot
|
||||
* report usage so the screen says to update it; else the sidebar row's host; else the first
|
||||
* The host the Usage modal shows: the picked host while it is connected, even one that cannot
|
||||
* report usage so the modal says to update it; else the sidebar row's host; else the first
|
||||
* connected host.
|
||||
*/
|
||||
export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null {
|
||||
export function resolveUsageModalHostId(choice: UsageHostChoice): string | null {
|
||||
const connected = choice.hosts.filter((host) => host.isConnected);
|
||||
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
|
||||
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
@@ -17,13 +16,9 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { buildUsageRoute } from "@/utils/host-routes";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { useUsagePreferences, type UsageDisplay } from "./display";
|
||||
import { useUsageHostId, useUsageHostSelection } from "./hosts";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import { UsageMeter } from "./meter";
|
||||
@@ -35,8 +30,7 @@ import {
|
||||
type PinnedUsageLayout,
|
||||
type PinnedUsageSource,
|
||||
} from "./pinned";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody } from "./usage-section";
|
||||
import { UsageModal } from "./usage-modal";
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
@@ -52,8 +46,8 @@ export function useHasUsageSummary(): boolean {
|
||||
|
||||
/**
|
||||
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
|
||||
* while no summary window has data, since the footer's Usage icon already opens the screen.
|
||||
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
|
||||
* while no summary window has data, since the footer's Usage icon already opens Usage.
|
||||
* Pressing it opens the Usage modal.
|
||||
*/
|
||||
export function UsageSidebarItem() {
|
||||
const { t } = useTranslation();
|
||||
@@ -66,30 +60,25 @@ export function UsageSidebarItem() {
|
||||
|
||||
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
|
||||
|
||||
/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */
|
||||
/**
|
||||
* One owner for the footer icon and summary: both open the Usage modal over the current screen.
|
||||
* The modal mounts on first open and unmounts once it has finished closing, so its host's reports
|
||||
* load only while it is shown.
|
||||
*/
|
||||
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const { display } = useUsagePreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sheetMounted, setSheetMounted] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const openUsage = useCallback(() => {
|
||||
if (isCompact) {
|
||||
setSheetMounted(true);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompact]);
|
||||
setMounted(true);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const closeUsage = useCallback(() => setOpen(false), []);
|
||||
const unmountUsage = useCallback(() => setMounted(false), []);
|
||||
|
||||
return (
|
||||
<OpenSidebarUsageContext.Provider value={openUsage}>
|
||||
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
{isCompact && sheetMounted ? (
|
||||
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
|
||||
) : null}
|
||||
</SidebarPopoverRoot>
|
||||
{children}
|
||||
{mounted ? <UsageModal visible={open} onClose={closeUsage} onDismiss={unmountUsage} /> : null}
|
||||
</OpenSidebarUsageContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void {
|
||||
return openUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls
|
||||
* in its title row.
|
||||
*/
|
||||
function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) {
|
||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
||||
if (!serverId) return null;
|
||||
return (
|
||||
<HostUsageSheet
|
||||
key={serverId}
|
||||
title={title}
|
||||
serverId={serverId}
|
||||
hosts={connectedHosts}
|
||||
onSelectHost={select}
|
||||
display={display}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostUsageSheet({
|
||||
title,
|
||||
serverId,
|
||||
hosts,
|
||||
onSelectHost,
|
||||
display,
|
||||
}: {
|
||||
title: string;
|
||||
serverId: string;
|
||||
hosts: UsageHost[];
|
||||
onSelectHost: (serverId: string) => void;
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
const hostSelection = useMemo(
|
||||
() => ({ hosts, serverId, onSelect: onSelectHost }),
|
||||
[hosts, onSelectHost, serverId],
|
||||
);
|
||||
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
|
||||
return (
|
||||
<SidebarPopoverSurface
|
||||
section="footer"
|
||||
title={title}
|
||||
sheetTrailing={controls}
|
||||
testID="sidebar-usage-sheet"
|
||||
>
|
||||
<View style={styles.sheetBody} testID="usage-expanded">
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</SidebarPopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
||||
const windows = sources.flatMap((source) => source.windows);
|
||||
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
||||
@@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
windowLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sheetBody: {
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody, UsageMessage } from "./usage-section";
|
||||
|
||||
interface UsageModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It
|
||||
* shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its
|
||||
* title row.
|
||||
*/
|
||||
export function UsageModal(props: UsageModalProps) {
|
||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
||||
if (!serverId) {
|
||||
return (
|
||||
<UsageModalFrame {...props}>
|
||||
<UsageMessage text={usageCopy.noHosts} />
|
||||
</UsageModalFrame>
|
||||
);
|
||||
}
|
||||
// Keyed by host so a refresh in flight on one host never shows on another's cards.
|
||||
return (
|
||||
<HostUsageModal
|
||||
key={serverId}
|
||||
{...props}
|
||||
serverId={serverId}
|
||||
hosts={connectedHosts}
|
||||
onSelectHost={select}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostUsageModal({
|
||||
serverId,
|
||||
hosts,
|
||||
onSelectHost,
|
||||
...props
|
||||
}: UsageModalProps & {
|
||||
serverId: string;
|
||||
hosts: UsageHost[];
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { display } = useUsagePreferences();
|
||||
const hostSelection = useMemo(
|
||||
() => ({ hosts, serverId, onSelect: onSelectHost }),
|
||||
[hosts, onSelectHost, serverId],
|
||||
);
|
||||
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
|
||||
return (
|
||||
<UsageModalFrame {...props} actions={controls}>
|
||||
<View testID={`usage-host-${serverId}`}>
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</UsageModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageModalFrame({
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
actions,
|
||||
children,
|
||||
}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const title = t(builtinSidebarNavLabelKey("usage"));
|
||||
const header = useMemo(() => ({ title, actions }), [actions, title]);
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onDismiss={onDismiss}
|
||||
desktopMaxWidth={640}
|
||||
contextBridge={null}
|
||||
testID="usage-modal"
|
||||
>
|
||||
<View testID="usage-modal-body">{children}</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { PageLayout } from "@/components/page-layout";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody, UsageMessage } from "./usage-section";
|
||||
|
||||
// The screen is reachable by URL, so there may be no history to go back to.
|
||||
function leaveUsage(): void {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}
|
||||
|
||||
export function UsageScreen() {
|
||||
const isFocused = useIsFocused();
|
||||
if (!isFocused) return <UsagePage>{null}</UsagePage>;
|
||||
return <FocusedUsageScreen />;
|
||||
}
|
||||
|
||||
function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<PageLayout title={usageCopy.title} onBack={leaveUsage} actions={actions} testID="usage-screen">
|
||||
{children}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function FocusedUsageScreen() {
|
||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
||||
if (!serverId) {
|
||||
return (
|
||||
<UsagePage>
|
||||
<UsageMessage text={usageCopy.noHosts} />
|
||||
</UsagePage>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostUsage key={serverId} serverId={serverId} hosts={connectedHosts} onSelectHost={select} />
|
||||
);
|
||||
}
|
||||
|
||||
function HostUsage({
|
||||
serverId,
|
||||
hosts,
|
||||
onSelectHost,
|
||||
}: {
|
||||
serverId: string;
|
||||
hosts: UsageHost[];
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { display } = useUsagePreferences();
|
||||
const hostSelection = useMemo(
|
||||
() => ({ hosts, serverId, onSelect: onSelectHost }),
|
||||
[hosts, onSelectHost, serverId],
|
||||
);
|
||||
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
|
||||
return (
|
||||
<UsagePage actions={controls}>
|
||||
<View testID={`usage-host-${serverId}`}>
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</UsagePage>
|
||||
);
|
||||
}
|
||||
@@ -428,10 +428,6 @@ export function buildSchedulesRoute() {
|
||||
return "/schedules" as const;
|
||||
}
|
||||
|
||||
export function buildUsageRoute() {
|
||||
return "/usage" as const;
|
||||
}
|
||||
|
||||
export function buildOpenProjectRoute() {
|
||||
return "/open-project" as const;
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ account metadata is unavailable. Labels can name accounts without becoming their
|
||||
Return logins in preference order and set `harness` to the owning harness's display name, such as
|
||||
Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them.
|
||||
|
||||
The host-wide Usage screen groups logins with the same key into one account card and tries them
|
||||
The host-wide Usage modal groups logins with the same key into one account card and tries them
|
||||
concurrently, preferring the discovery order when selecting a result. If any login returns
|
||||
`available`, it shows usage with no errors. If all fail, it shows
|
||||
every login's error on a separate line with its harness label and its own remedy.
|
||||
|
||||
Reference in New Issue
Block a user