From 4ede81e84138e5c176de2b80fba38e97005b469e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 12:54:09 +0100 Subject: [PATCH] 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 --- docs/glossary.md | 4 +- docs/plugins.md | 2 +- ...age-screen.spec.ts => usage-modal.spec.ts} | 43 +++-- .../app/e2e/browser/usage-settings.spec.ts | 9 +- .../e2e/browser/usage-sidebar-item.spec.ts | 173 +++++++++++------- .../app/e2e/support/helpers/usage-login.ts | 4 - .../e2e/support/helpers/usage-sidebar-item.ts | 64 ++++--- packages/app/src/app/_layout.tsx | 2 - packages/app/src/app/usage.tsx | 10 - .../components/sidebar/sidebar-popover.tsx | 2 +- packages/app/src/usage/controls.tsx | 3 +- packages/app/src/usage/hosts.ts | 6 +- packages/app/src/usage/index.ts | 1 - packages/app/src/usage/model.test.ts | 20 +- packages/app/src/usage/model.ts | 6 +- packages/app/src/usage/sidebar-item.tsx | 102 ++--------- packages/app/src/usage/usage-modal.tsx | 93 ++++++++++ packages/app/src/usage/usage-screen.tsx | 72 -------- packages/app/src/utils/host-routes.ts | 4 - public-docs/plugins/reference.md | 2 +- 20 files changed, 308 insertions(+), 314 deletions(-) rename packages/app/e2e/browser/{usage-screen.spec.ts => usage-modal.spec.ts} (94%) delete mode 100644 packages/app/src/app/usage.tsx create mode 100644 packages/app/src/usage/usage-modal.tsx delete mode 100644 packages/app/src/usage/usage-screen.tsx diff --git a/docs/glossary.md b/docs/glossary.md index f9eb458173..3ed97d1fc3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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: `:`. 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`). diff --git a/docs/plugins.md b/docs/plugins.md index 387f24f46b..7cabeeda84 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts similarity index 94% rename from packages/app/e2e/browser/usage-screen.spec.ts rename to packages/app/e2e/browser/usage-modal.spec.ts index 1dfe89ae19..6f546078bf 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -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.", diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 2b8d982de7..1b509f5640 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -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 = ''; @@ -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(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index e43ccdc4a3..fb738b3cb5 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -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"); }); diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 985e7bd7ac..6129f0259e 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -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(); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index d21473867e..8cc3c07a00 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -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 { - 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 { +/** + * 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 { 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 { - 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 { + await usageItem(page).click(); + await expect(usageModal(page)).toBeVisible(); } -export async function closeUsageSheet(page: Page): Promise { - 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 { + 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 { + 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 { + 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 { await openUsageOptions(page); await summaryInSidebarSwitch(page).click(); @@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { 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 { - await page.getByRole("button", { name: "Back", exact: true }).first().click(); - await expect(page).not.toHaveURL(/\/usage$/); -} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2eb4e5185a..6e9b506b55 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || - pathname === "/usage" || routeHasKnownHost); return {children}; @@ -896,7 +895,6 @@ function RootStack() { - diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx deleted file mode 100644 index 2928cca3f3..0000000000 --- a/packages/app/src/app/usage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { UsageScreen } from "@/usage"; - -export default function UsageRoute() { - return ( - - - - ); -} diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx index ec13605d08..e57f3cf6ac 100644 --- a/packages/app/src/components/sidebar/sidebar-popover.tsx +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -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. */ diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index d8e2ce74d1..020faf61ec 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -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; diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 450e0a6157..dba21dc316 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -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 }; } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index c9ae924f10..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -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, diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 1276157120..ad293bb23e 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -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(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 2b2e4c54cf..a32da7ea4d 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -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; diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index fc8f28c1c0..d90013d8bf 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -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 ( - - {children} - {isCompact && sheetMounted ? ( - - ) : null} - + {children} + {mounted ? : null} ); } @@ -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 ( - - ); -} - -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 ( - - - - - - ); -} - 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], - }, })); diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -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 ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +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 ( + + + + + + ); +} + +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 ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx deleted file mode 100644 index 559d15095e..0000000000 --- a/packages/app/src/usage/usage-screen.tsx +++ /dev/null @@ -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 {null}; - return ; -} - -function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { - return ( - - {children} - - ); -} - -function FocusedUsageScreen() { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) { - return ( - - - - ); - } - return ( - - ); -} - -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 ( - - - - - - ); -} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 1dc3ff48bb..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -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; } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 086f910599..da146de78d 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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.