diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index 24b90bfe4e..b36ea6ba98 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -40,22 +40,21 @@ async function qaScreenshot(page: Page, name: string, area?: Locator) { else await page.screenshot({ path: file }); } -/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */ +/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */ async function expectFooterRow(page: Page, row: Locator) { // Polled: on compact the drawer is still sliding in when the row first shows. await expect .poll(async () => { const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!; - const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!; + const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!; const box = (await row.boundingBox())!; return { - belowAddProject: box.y >= addProject.y + addProject.height, - aboveIconRow: box.y + box.height <= hosts.y, + aboveBottomLine: box.y + box.height <= addProject.y, alignedLeft: Math.abs(box.x - addProject.x) < 1, - fullWidth: Math.abs(box.width - addProject.width) < 1, + alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1, }; }) - .toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true }); + .toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true }); } function botRow(page: Page, rowId: string): Locator { @@ -188,7 +187,7 @@ test.describe("Plugin sidebar items", () => { await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0); }); - await test.step("the footer row renders as a row between Add project and the icon row", async () => { + await test.step("the footer row renders as a row above the bottom line", async () => { await expect(sync).toHaveAccessibleName("Sync"); await expectFooterRow(page, sync); await expect(visibleTestId(page, "sidebar-usage")).toBeVisible(); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 29a6807c99..038ea63f95 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -1,8 +1,18 @@ import { expect, test } from "../support/fixtures"; +import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + claudeAndCodexReports, + expectPinnedUsage, + openCompactSidebar, +} from "../support/helpers/usage-sidebar-item"; import { gotoAppShell } from "../support/helpers/app"; import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; import { expectFooterIconRow, + expectFooterSeparator, + hoverFooterAddProject, + footerScreenshot, expectFooterItemHidden, expectFooterOrder, expectFooterSettingsKeys, @@ -21,6 +31,50 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; +test("fixed footer line keeps the labeled Add project button and four icons", async ({ page }) => { + test.setTimeout(120_000); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]); + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31%", "7%"]); + await expectFooterIconRow(page); + await expectFooterSeparator(page, true); + await footerScreenshot(page, "footer-desktop-with-rows"); + await hoverFooterAddProject(page); + await footerScreenshot(page, "footer-desktop-add-project-tooltip"); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await openSidebarNavSettings(page); + await expectFooterSettingsKeys(page, ["usage"]); + await setFooterItemVisible(page, "usage", false); + await leaveSettings(page); + await expectFooterSeparator(page, false); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-desktop-without-rows"); + await page.setViewportSize({ width: 390, height: 844 }); + await openCompactSidebar(page); + await expectFooterSeparator(page, false); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-compact-without-rows"); + await page.locator('[data-testid="sidebar-add-project"]:visible').hover(); + await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0); + await footerScreenshot(page, "footer-compact-add-project-hover"); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await page.setViewportSize({ width: 1440, height: 900 }); + await openSidebarNavSettings(page); + await setFooterItemVisible(page, "usage", true); + await leaveSettings(page); + await page.setViewportSize({ width: 390, height: 844 }); + await openCompactSidebar(page); + await expectFooterSeparator(page, true); + await expectFooterIconRow(page); + await footerScreenshot(page, "footer-compact-with-rows"); +}); + test.describe("Sidebar items in Appearance settings", () => { test("owner reorders and hides top-level sidebar items", async ({ page }) => { await gotoAppShell(page); @@ -147,6 +201,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => { await leaveSettings(page); await expectFooterOrder(page, ["usage", syncKey]); await expectFooterIconRow(page); + await expectFooterSeparator(page, true); }); await test.step("moving Sync up and hiding the Usage item changes the rows", async () => { @@ -160,6 +215,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => { page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`), ).toBeVisible(); await expectFooterIconRow(page); + await expectFooterSeparator(page, true); }); await test.step("the rows keep that shape across a reload", async () => { @@ -169,6 +225,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => { ).toBeVisible({ timeout: 30_000 }); await expectFooterItemHidden(page, "usage"); await expectFooterIconRow(page); + await expectFooterSeparator(page, true); await openSidebarNavSettings(page); await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]); }); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 37e0ae26aa..78deb7835f 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -1,10 +1,22 @@ +import { randomUUID } from "node:crypto"; import path from "node:path"; import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; +import { addConnectedHostAndReload } from "../support/helpers/hosts"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { getServerId } from "../support/helpers/server-id"; -import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + installUsageReportsFixture, + type UsageListRequest, + type UsageReportsFixture, +} from "../support/helpers/usage-reports"; +import { expectPinnedUsage, usageItem } from "../support/helpers/usage-sidebar-item"; + +function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] { + return usage.listRequests().filter((request) => request.forceRefresh); +} // Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. function twoHoursAgo(): string { @@ -20,15 +32,25 @@ async function qaScreenshot(page: Page, name: string) { await page.screenshot({ path: path.join(directory, `${name}.png`) }); } -/** The host selector names the host shown, even when it is the only one. */ -async function shownHostLabel(page: Page): Promise { - const selector = page.locator('[data-testid="usage-host-switcher"]:visible'); - await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 }); - return (await selector.innerText()).trim(); +function hostFilter(page: Page) { + return page.locator('[data-testid="usage-host-filter-trigger"]:visible'); +} + +function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { + return { + id: `${sourceId}:a`, + account: {}, + fetchedAt: twoHoursAgo(), + sourceId, + sourceLabel: `${sourceId} plan`, + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] }, + }; } test.describe("usage screen", () => { - test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => { + test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ + page, + }) => { test.setTimeout(120_000); const serverId = getServerId(); const usage = await installUsageReportsFixture(page, { @@ -64,10 +86,7 @@ test.describe("usage screen", () => { const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); - const hostLabel = await shownHostLabel(page); - await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName( - `Usage host: ${hostLabel}`, - ); + await expect(hostFilter(page)).toHaveCount(0); await qaScreenshot(page, "phase7-usage-screen-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); @@ -103,13 +122,13 @@ 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. const usage = await installUsageReportsFixture(page, { lists: [ - [alpha(31, twoHoursAgo()), beta], - () => [ - alpha(58, new Date().toISOString()), - { ...beta, fetchedAt: new Date().toISOString() }, - ], + (request) => + request.forceRefresh + ? [alpha(58, new Date().toISOString())] + : [alpha(31, twoHoursAgo()), beta], ], }); @@ -120,11 +139,9 @@ test.describe("usage screen", () => { const alphaRefresh = group.getByTestId("usage-refresh").first(); await alphaRefresh.click(); - await usage.waitForListRequests(2); - expect(usage.listRequests()).toEqual([ - { forceRefresh: false, reportIds: undefined }, - { forceRefresh: true, reportIds: ["alpha:a"] }, - ]); + await expect + .poll(() => forcedRefreshes(usage)) + .toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]); await expect(group.getByText("58%")).toBeVisible(); await expect(group.getByText("12%")).toBeVisible(); @@ -169,13 +186,60 @@ test.describe("usage screen", () => { await gotoAppShell(page); await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); - const hostLabel = await shownHostLabel(page); await expect( - page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, { - exact: true, - }), + page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), ).toBeVisible({ timeout: 10_000 }); await qaScreenshot(page, "phase7-usage-update-host"); expect(usage.listRequests()).toHaveLength(0); }); + + test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({ + page, + }) => { + test.setTimeout(420_000); + const primaryServerId = getServerId(); + const secondary = await startIsolatedHostDaemon( + `srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`, + ); + try { + await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] }); + await installUsageReportsFixture(page, { + port: secondary.port, + lists: [[weeklyReport("beta", 12)]], + }); + await gotoAppShell(page); + await addConnectedHostAndReload(page, { + serverId: secondary.serverId, + label: "Secondary box", + port: secondary.port, + }); + + // Nothing picked and no workspace open: the first host. + await expectPinnedUsage(page, ["31%"]); + await usageItem(page).click(); + await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); + + await hostFilter(page).click(); + await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click(); + await expect( + 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 expectPinnedUsage(page, ["12%"]); + + // The e2e seed resets the host list on every load, so reopening re-adds the second host. + await addConnectedHostAndReload(page, { + serverId: secondary.serverId, + label: "Secondary box", + port: secondary.port, + }); + await expectPinnedUsage(page, ["12%"]); + await expect( + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await secondary.close().catch(() => undefined); + } + }); }); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index d99b93fdd2..1c590c38be 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -3,10 +3,17 @@ import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; -import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + installUsageReportsFixture, + type UsageReportsFixture, +} from "../support/helpers/usage-reports"; const ICON = ''; +function forcedRefreshCount(usage: UsageReportsFixture): number { + return usage.listRequests().filter((request) => request.forceRefresh).length; +} + function report(input: { sourceId: string; sourceLabel: string; @@ -69,7 +76,6 @@ test.describe("usage settings", () => { await gotoAppShell(page); await openSettings(page); - expect(usage.listRequests()).toHaveLength(0); await openSettingsHostSection(page, serverId, "usage"); await usage.waitForListRequests(1); @@ -92,20 +98,14 @@ test.describe("usage settings", () => { test.setTimeout(120_000); const serverId = getServerId(); const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }]; + // The sidebar summary and the section each load reports; only Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ - [ + (request) => [ report({ sourceId: "alpha", sourceLabel: "Alpha plan", - report: { windows: windows(23) }, - }), - ], - [ - report({ - sourceId: "alpha", - sourceLabel: "Alpha plan", - report: { windows: windows(64) }, + report: { windows: windows(request.forceRefresh ? 64 : 23) }, }), ], ], @@ -114,13 +114,12 @@ test.describe("usage settings", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); - await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 }); + const card = page.getByTestId("usage-card"); + await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); await page.getByRole("button", { name: "Refresh", exact: true }).click(); - await usage.waitForListRequests(2); - - expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true }); - await expect(page.getByText("64%")).toBeVisible(); + await expect.poll(() => forcedRefreshCount(usage)).toBe(1); + await expect(card.getByText("64%")).toBeVisible(); }); test("asks to update a host without usage support and never calls it", async ({ page }) => { diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 4fdd06d62b..e09e4d22c6 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -8,7 +8,6 @@ import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sideba import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, - expectNoPinnedUsage, expectOnUsageScreen, expectPinnedUsage, leaveUsageScreen, @@ -33,7 +32,7 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k await page.waitForTimeout(600); // Expo's fast-refresh indicator sits over the footer's Hosts icon. await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); - const file = path.join(directory, `phase4-${name}.png`); + const file = path.join(directory, `${name}.png`); if (area.kind === "element") { await area.locator.screenshot({ path: file }); return; @@ -42,14 +41,9 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k await page.screenshot({ path: file, clip }); } -/** The sidebar footer, from Add project down to the icon row, with some margin. */ +/** Includes summary rows above the fixed icon row. */ async function footerClip(page: Page) { - const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!; - const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!; - const margin = 16; - const x = Math.max(0, top.x - margin); - const y = Math.max(0, top.y - margin); - return { x, y, width: 300, height: bottom.y + bottom.height + margin - y }; + return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!; } test.describe("Usage item", () => { @@ -63,21 +57,32 @@ test.describe("Usage item", () => { await gotoAppShell(page); const screen = page.getByTestId(`usage-host-${serverId}`); - await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => { + await test.step("a fresh device shows default windows, which opens the Usage screen", async () => { await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await expectNoPinnedUsage(page); - await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" }); + await expectPinnedUsage(page, ["31%", "7%"]); + await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); + await openSidebarNavSettings(page); + await qaScreenshot(page, "desktop-settings-sidebar-footer"); + await page.setViewportSize(COMPACT); + await qaScreenshot(page, "compact-settings-sidebar-footer"); + await gotoAppShell(page); + await openCompactSidebar(page); + await expectPinnedUsage(page, ["31%", "7%"]); + await qaScreenshot(page, "compact-footer-defaults"); + await page.setViewportSize(WIDE); + await gotoAppShell(page); await usageItem(page).click(); await expectOnUsageScreen(page); }); await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => { await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); - // One host: the selector still names it. - await expect( - page.locator('[data-testid="usage-host-switcher"]:visible'), - ).toHaveAccessibleName(/^Usage host: .+/); + // One host: no host filter, as on History. + await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount( + 0, + ); await togglePin(screen, "Claude", "Session"); + await expectPinnedUsage(page, ["31%"]); await togglePin(screen, "Codex", "Weekly"); await expectPinnedUsage(page, ["31%", "12%"]); await expect(usageItem(page)).not.toHaveText("Usage"); @@ -95,7 +100,8 @@ test.describe("Usage item", () => { await test.step("remaining flips the Usage item and the Usage screen", async () => { await showUsageAs(page, "remaining"); - await expectPinnedUsage(page, ["69% left", "88% left"]); + await expectPinnedUsage(page, ["69%", "88%"]); + await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); await expect( screen.getByTestId("usage-report-claude:default").getByText("69% left"), ).toBeVisible(); @@ -117,7 +123,7 @@ test.describe("Usage item", () => { await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); - await expectPinnedUsage(page, ["69% left", "88% left"]); + await expectPinnedUsage(page, ["69%", "88%"]); await qaScreenshot(page, "compact-footer"); await usageItem(page).click(); const sheet = usageSheet(page); @@ -129,10 +135,7 @@ test.describe("Usage item", () => { await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName( /^Pin Claude Session, \d+% left( · .+)?$/, ); - // The sheet carries the Usage screen's controls, host selector included. - await expect( - page.locator('[data-testid="usage-host-switcher"]:visible'), - ).toHaveAccessibleName(/^Usage host: .+/); + // The sheet carries the Usage screen's controls. await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); await expect(page).not.toHaveURL(/\/usage$/); const sheetBox = (await sheet.boundingBox())!; @@ -150,7 +153,7 @@ test.describe("Usage item", () => { await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); - await expectPinnedUsage(page, ["69% left", "88% left"]); + await expectPinnedUsage(page, ["69%", "88%"]); await expect( page.locator('[data-testid="usage-display-remaining"]:visible').first(), ).toHaveAttribute("aria-selected", "true"); @@ -167,13 +170,13 @@ test.describe("Usage item", () => { await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); }); - await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => { + await test.step("unpinning both brings back default windows, and that survives a reload", async () => { await togglePin(screen, "Claude", "Session"); await togglePin(screen, "Codex", "Weekly"); - await expectNoPinnedUsage(page); + await expectPinnedUsage(page, ["69%", "93%"]); await page.reload(); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); - await expectNoPinnedUsage(page); + await expectPinnedUsage(page, ["69%", "93%"]); await gotoAppShell(page); await usageItem(page).click(); await expectOnUsageScreen(page); @@ -190,3 +193,31 @@ test.describe("Usage item", () => { }); }); }); + +test("released hosts supply source logos through the client conversion", async ({ page }) => { + await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + providerUsageListOnly: true, + }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31%", "7%"]); + // Source logos are decorative SVGs with no accessible role. Their path data distinguishes + // the source artwork from the fallback gauge. + const claudePath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!; + const codexPath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!; + const summary = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + 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); + const screen = page.getByTestId(`usage-host-${getServerId()}`); + await expect( + screen.getByTestId("usage-report-claude").locator("svg path").first(), + ).toHaveAttribute("d", claudePath); + await expect( + screen.getByTestId("usage-report-codex").locator("svg path").first(), + ).toHaveAttribute("d", codexPath); + await qaScreenshot(page, "released-host-usage-screen"); +}); diff --git a/packages/app/e2e/support/helpers/add-project-flow.ts b/packages/app/e2e/support/helpers/add-project-flow.ts index c7b15df456..bb88264d37 100644 --- a/packages/app/e2e/support/helpers/add-project-flow.ts +++ b/packages/app/e2e/support/helpers/add-project-flow.ts @@ -47,7 +47,7 @@ async function openAddProjectFlowSurface( page: Page, expectedPage: "host" | "method", ): Promise { - await page.getByTestId("sidebar-add-project").click(); + await page.locator('[data-testid="sidebar-add-project"]:visible').click(); await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 }); await expectAddProjectPage(page, expectedPage); } diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index f57ee32497..b17b7b6bfb 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { expect, type Locator, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings"; @@ -218,13 +219,14 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise { const boxes = await Promise.all( FOOTER_ICON_TEST_IDS.map((testID) => @@ -232,10 +234,49 @@ export async function expectFooterIconRow(page: Page): Promise { ), ); const [first, ...rest] = boxes.map((box) => box!); + expect(first.width).toBeGreaterThan(rest[0]!.width); + await expect( + page + .locator('[data-testid="sidebar-add-project"]:visible') + .getByText("Add project", { exact: true }), + ).toBeVisible(); let previous = first; for (const box of rest) { - expect(Math.abs(box.y - first.y)).toBeLessThan(2); + expect(Math.abs(box.y + box.height / 2 - first.y - first.height / 2)).toBeLessThan(2); expect(box.x).toBeGreaterThan(previous.x); previous = box; } } + +export async function expectFooterSeparator(page: Page, shown: boolean): Promise { + await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS( + "border-top-width", + "1px", + ); + await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS( + "border-top-width", + "0px", + ); + const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible'); + await expect(separator).toHaveCount(shown ? 1 : 0); + expect( + await separator.evaluateAll((elements) => + elements.map((element) => getComputedStyle(element).borderBottomWidth), + ), + ).toEqual(shown ? ["1px"] : []); +} + +export async function hoverFooterAddProject(page: Page): Promise { + await page.locator('[data-testid="sidebar-add-project"]:visible').hover(); + const tooltip = page.getByTestId("sidebar-add-project-tooltip"); + await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible(); + await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible(); +} + +export async function footerScreenshot(page: Page, name: string): Promise { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + await page.waitForTimeout(600); + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + await page.screenshot({ path: path.join(directory, `${name}.png`) }); +} diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 339475b8e6..4c853045fc 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -1,20 +1,30 @@ import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; -import { daemonWsRoutePattern } from "./daemon-port"; +import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; + +export interface UsageListRequest { + forceRefresh: boolean; + reportIds?: string[]; +} export interface UsageReportsFixture { - listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>; + listRequests(): UsageListRequest[]; waitForListRequests(count: number): Promise; } interface UsageReportsFixtureOptions { /** * Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that - * request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`). + * request; a function builds the response from the request when it arrives (e.g. a fresh + * `fetchedAt`, or a different answer to a forced refresh). */ - lists?: Array UsageListResponse)>; + lists?: Array UsageListResponse)>; /** False simulates a host with no usage reporting capability. */ usageSupported?: boolean; + /** Released hosts expose provider.usage.list with no source icons. */ + providerUsageListOnly?: boolean; + /** The host daemon's port; defaults to the E2E daemon. */ + port?: number; } type UsageListResponse = UsageReportEntry[] | { error: string }; @@ -38,7 +48,11 @@ function getSessionMessage(message: WebSocketMessage): Record | return envelope.message as Record; } -function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null { +function withUsageSupportFeature( + message: WebSocketMessage, + enabled: boolean, + providerUsageListOnly: boolean, +): string | null { const envelope = parseJson(message) as { type?: unknown; message?: { type?: unknown; payload?: Record }; @@ -59,7 +73,11 @@ function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): s ...envelope.message, payload: { ...payload, - features: { ...features, usageSources: enabled, providerUsageList: enabled }, + features: { + ...features, + usageSources: enabled && !providerUsageListOnly, + providerUsageList: enabled, + }, }, }, }); @@ -93,23 +111,30 @@ export async function installUsageReportsFixture( page: Page, options: UsageReportsFixtureOptions, ): Promise { - const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = []; + const listRequests: UsageListRequest[] = []; const listCounter = createCounter(); const usageSupported = options.usageSupported ?? true; + const providerUsageListOnly = options.providerUsageListOnly ?? false; + const requestType = providerUsageListOnly + ? "provider.usage.list.request" + : "usage.list_reports.request"; - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const route = + options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`); + await page.routeWebSocket(route, (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { const request = getSessionMessage(message); const requestId = request?.requestId; - if (request?.type === "usage.list_reports.request" && typeof requestId === "string") { - listRequests.push({ + if (request?.type === requestType && typeof requestId === "string") { + const listRequest: UsageListRequest = { forceRefresh: request.forceRefresh === true, reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, - }); + }; + listRequests.push(listRequest); const scripted = pick(options.lists ?? [[]], listRequests.length - 1); - const response = typeof scripted === "function" ? scripted() : scripted; + const response = typeof scripted === "function" ? scripted(listRequest) : scripted; if ("error" in response) { ws.send( JSON.stringify({ @@ -118,7 +143,7 @@ export async function installUsageReportsFixture( type: "rpc_error", payload: { requestId, - requestType: "usage.list_reports.request", + requestType, error: response.error, code: "transport", }, @@ -130,12 +155,23 @@ export async function installUsageReportsFixture( } const ids = Array.isArray(request.reportIds) ? request.reportIds : null; const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response; - ws.send( - JSON.stringify({ - type: "session", - message: { type: "usage.list_reports.response", payload: { requestId, reports } }, - }), - ); + const reply = providerUsageListOnly + ? { + type: "provider.usage.list.response", + payload: { + requestId, + fetchedAt: new Date().toISOString(), + providers: reports.map((entry) => ({ + providerId: entry.sourceId, + displayName: entry.sourceLabel, + fetchedAt: entry.fetchedAt, + ...entry.report, + planLabel: entry.report.planLabel ?? null, + })), + }, + } + : { type: "usage.list_reports.response", payload: { requestId, reports } }; + ws.send(JSON.stringify({ type: "session", message: reply })); listCounter.increment(); return; } @@ -144,7 +180,9 @@ export async function installUsageReportsFixture( server.onMessage((message) => { const serverInfo = - typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null; + typeof message === "string" + ? withUsageSupportFeature(message, usageSupported, providerUsageListOnly) + : null; ws.send(serverInfo ?? message); }); }); diff --git a/packages/app/src/components/hosts/host-filter.tsx b/packages/app/src/components/hosts/host-filter.tsx index ffdb6a2130..d073dc943d 100644 --- a/packages/app/src/components/hosts/host-filter.tsx +++ b/packages/app/src/components/hosts/host-filter.tsx @@ -2,13 +2,13 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, Server } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { HostProfile } from "@/types/host-connection"; import type { Theme } from "@/styles/theme"; import { ALL_HOSTS_OPTION_ID, getHostPickerLabel, HostPicker, HostStatusDotSlot, + type HostPickerHost, } from "@/components/hosts/host-picker"; const ThemedServer = withUnistyles(Server); @@ -16,7 +16,7 @@ const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); export interface HostFilterProps { - hosts: HostProfile[]; + hosts: HostPickerHost[]; selectedHost: string; onSelectHost: (serverId: string) => void; /** @@ -29,9 +29,9 @@ export interface HostFilterProps { } /** - * The "All hosts / " filter pill shared by the History and Schedules - * screens: an anchored HostPicker with `includeAllHost`, hidden by the caller - * when only one host exists. Copies the History layout exactly. + * The "All hosts / " host pill shared by History, Schedules, Usage, the label manager and + * plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without + * `includeAllHost` it picks the one host a surface shows. */ export function HostFilter({ hosts, diff --git a/packages/app/src/components/hosts/host-picker.tsx b/packages/app/src/components/hosts/host-picker.tsx index 9bc8955e64..ced0be3d55 100644 --- a/packages/app/src/components/hosts/host-picker.tsx +++ b/packages/app/src/components/hosts/host-picker.tsx @@ -24,7 +24,7 @@ export { const SEARCHABLE_THRESHOLD = 10; type RenderHostOption = NonNullable; -interface HostPickerHost { +export interface HostPickerHost { serverId: string; label: string; } diff --git a/packages/app/src/components/hosts/host-switcher.tsx b/packages/app/src/components/hosts/host-switcher.tsx deleted file mode 100644 index 9172ab3e5d..0000000000 --- a/packages/app/src/components/hosts/host-switcher.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { ChevronDown } from "lucide-react-native"; -import { useCallback, useRef, useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; -import { HostPicker } from "./host-picker"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface HostSwitcherHost { - serverId: string; - label: string; -} - -/** A compact trigger showing the chosen host, opening the host picker to change it. */ -export function HostSwitcher({ - hosts, - value, - onSelect, - title, - accessibilityLabel, - testID, -}: { - hosts: HostSwitcherHost[]; - value: string; - onSelect: (serverId: string) => void; - /** The picker's title. */ - title: string; - /** Prefixes the selected host's label: "Plugin host: Laptop". */ - accessibilityLabel: string; - testID: string; -}) { - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value; - const openPicker = useCallback(() => setOpen(true), []); - return ( - - - - - {selectedLabel} - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - trigger: { - maxWidth: 180, - minHeight: 32, - paddingHorizontal: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface1, - }, - label: { - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b1de36c8ea..c2bff3febc 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -23,6 +23,7 @@ import { } from "@/components/sidebar-resize-handle-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; +import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; @@ -309,7 +310,7 @@ function FooterIconButton({ )} - + @@ -367,7 +368,7 @@ function FooterAddProjectButton({ }} - + @@ -473,37 +474,39 @@ function SidebarFooter({ const settingsKeys = useShortcutKeys("toggle-settings"); return ( - - + - - - - - + + + + + + + ); @@ -516,21 +519,24 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void const visibleItems = items.filter((item) => item.visible); if (visibleItems.length === 0) return null; return ( - - {visibleItems.map((item) => - item.kind === "plugin" ? ( - - ) : ( - - ), - )} - + <> + + {visibleItems.map((item) => + item.kind === "plugin" ? ( + + ) : ( + + ), + )} + + + ); } @@ -961,32 +967,32 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - sidebarFooter: { - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], + footerContainer: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, - // Every footer row is 28px tall with `spacing[1]` between rows, the same as the gap above. - footerRows: { - gap: theme.spacing[1], + sidebarFooter: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[3], }, footerIconRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], + flexShrink: 0, }, - // The leading glyph sits on the footer icons' rail: a 16px icon centered in a 28px button - // starts 6px in, `spacing[1.5]`. footerAddProjectButton: { minWidth: 0, - minHeight: 28, + minHeight: 32, + flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1.5], + paddingVertical: theme.spacing[1.5], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, footerAddProjectButtonHovered: { @@ -1002,6 +1008,12 @@ const styles = StyleSheet.create((theme) => ({ footerAddProjectLabelHovered: { color: theme.colors.foreground, }, + // Usage and plugin rows sit above the restored footer line. + footerRows: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[3], + gap: theme.spacing[1], + }, footerIconButton: { width: 28, height: 28, diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx index 864e56361f..30310d84ad 100644 --- a/packages/app/src/components/sidebar/sidebar-separator.tsx +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles"; * padded container so the line runs the full sidebar width — matching the * separator beneath the workspace sidebar's "New agent" header. */ -export function SidebarSeparator() { - return ; +export function SidebarSeparator({ testID }: { testID?: string } = {}) { + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 3797d76e8b..9761256278 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -267,6 +267,7 @@ describe("loadAppSettingsFromStorage", () => { expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({ displayAs: "used", pinned: [], + serverId: null, }); const deps = makeDeps({ @@ -278,6 +279,7 @@ describe("loadAppSettingsFromStorage", () => { { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ], + serverId: "server", }, }), }), @@ -289,6 +291,7 @@ describe("loadAppSettingsFromStorage", () => { { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ], + serverId: "server", }); }); @@ -296,7 +299,11 @@ describe("loadAppSettingsFromStorage", () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ - usage: { displayAs: "percent", pinned: [{ sourceId: "claude", windowId: "weekly" }] }, + usage: { + displayAs: "percent", + pinned: [{ sourceId: "claude", windowId: "weekly" }], + serverId: 42, + }, }), }), }); @@ -304,6 +311,7 @@ describe("loadAppSettingsFromStorage", () => { expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "used", pinned: [{ sourceId: "claude", windowId: "weekly" }], + serverId: null, }); }); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index c6e7b0a6cc..f2f9997749 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -9,7 +9,7 @@ import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostSwitcher } from "@/components/hosts/host-switcher"; +import { HostFilter } from "@/components/hosts/host-filter"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; @@ -82,7 +82,7 @@ function SurfaceRenderer({ const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); -function PluginHostSwitcher({ +function PluginHostFilter({ serverId, pluginId, identity, @@ -117,13 +117,12 @@ function PluginHostSwitcher({ if (!show) return null; return ( - ); } @@ -192,7 +191,7 @@ export function PluginSurfaceScreen() { () => ( <> {identity ? ( - Sidebar: one card per section. The footer's Add project row and icon row are fixed and not listed. */ +/** Settings > Sidebar: one card per section. The footer's bottom line is fixed and not listed. */ export function SidebarNavSection(): ReactElement { return ( <> diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index 33604b72e9..4211a44410 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -299,6 +299,7 @@ describe("footer section", () => { section: "footer", pluginGroups: [sync], preferences: [ + { key: "add-project", visible: false }, { key: "hosts", visible: false }, { key: "import", visible: false }, { key: "help", visible: false }, diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index 9691ec8b80..40e4db636f 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -4,8 +4,8 @@ import type { PluginSidebarSection } from "@/plugins/types"; export type SidebarSection = PluginSidebarSection; /** - * Each section's built-in items in their default order. The footer's Add project row and its - * icon row (Hosts, Import session, Help and support, Settings) are fixed and not items. + * Each section's built-in items in their default order. The footer's bottom line + * (Add project and the Hosts, Import session, Help and support, Settings icons) is fixed. */ export const BUILTIN_SIDEBAR_ITEM_IDS = { header: ["new-workspace", "history", "search", "schedules"], diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 582f35102b..b7942fddd9 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -1,7 +1,7 @@ import { RotateCw } from "lucide-react-native"; import { View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { HostSwitcher } from "@/components/hosts/host-switcher"; +import { HostFilter } from "@/components/hosts/host-filter"; import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; @@ -25,9 +25,9 @@ export interface UsageHostSelection { } /** - * The controls on the right of every usage title row: the host selector, which also names the - * host shown, the used/remaining toggle and Refresh. A host that cannot report usage keeps only - * the selector. + * The controls on the right of every usage title row: the host filter when there is more than one + * host, the used/remaining toggle and Refresh. A host that cannot report usage keeps only the + * host filter. */ export function UsageControls({ view, @@ -45,14 +45,14 @@ export function UsageControls({ const iconSize = paneContentToolbarIconSize(compact); return ( - {hostSelection && hostSelection.hosts.length > 0 ? ( - 1 ? ( + ) : null} {view.kind === "unavailable" ? null : ( @@ -78,6 +78,10 @@ export function UsageControls({ ); } +function usageHostOptionTestID(serverId: string): string { + return `usage-host-filter-item-${serverId}`; +} + const styles = StyleSheet.create((theme) => ({ controls: { flexShrink: 1, diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index ec42581fbe..5d84805ea2 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -16,7 +16,6 @@ export const usageCopy = { clientUnavailable: "Host connection is not ready", retry: "Try again", pin: "Pin", - host: "Usage host", displayUsed: "Used", displayRemaining: "Remaining", } as const; diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 5b6597ecdd..450e0a6157 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -1,9 +1,16 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; import { useActiveWorkspaceSelection, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { resolveUsageHostId, resolveUsageScreenHostId, type UsageHost } from "./model"; +import { + resolveUsageHostId, + resolveUsageScreenHostId, + type UsageHost, + type UsageHostChoice, +} from "./model"; +import { setUsageHost } from "./preferences"; import { useUsageHosts } from "./queries"; /** The active workspace's host; off a workspace route, the last workspace visited. */ @@ -13,27 +20,40 @@ function useActiveServerId(): string | null { return active?.serverId ?? last?.serverId ?? null; } +function useUsageHostChoice(): UsageHostChoice { + const { settings } = useAppSettings(); + return { + pickedServerId: settings.usage.serverId, + activeServerId: useActiveServerId(), + hosts: useUsageHosts(), + }; +} + /** The host the sidebar Usage row reads, or null when none reports usage. */ export function useUsageHostId(): string | null { - return resolveUsageHostId(useActiveServerId(), useUsageHosts()); + return resolveUsageHostId(useUsageHostChoice()); } /** - * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. The pick - * is the view's state: it resets when the view unmounts. + * The host the Usage screen or the compact usage sheet 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(): { serverId: string | null; connectedHosts: UsageHost[]; select: (serverId: string) => void; } { - const hosts = useUsageHosts(); - const activeServerId = useActiveServerId(); - const [selectedServerId, select] = useState(null); - const connectedHosts = useMemo(() => hosts.filter((host) => host.isConnected), [hosts]); - return { - serverId: resolveUsageScreenHostId({ selectedServerId, activeServerId, hosts }), - connectedHosts, - select, - }; + const { updateSettings } = useAppSettings(); + const choice = useUsageHostChoice(); + const connectedHosts = useMemo( + () => choice.hosts.filter((host) => host.isConnected), + [choice.hosts], + ); + const select = useCallback( + (serverId: string) => { + void updateSettings((current) => ({ usage: setUsageHost(current.usage, serverId) })); + }, + [updateSettings], + ); + return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; } diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index fd46a2e476..834746e1a7 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -84,52 +84,65 @@ const hosts: UsageHost[] = [ ]; describe("resolveUsageHostId", () => { - it("reads the active workspace's host", () => { - expect(resolveUsageHostId("b", hosts)).toBe("b"); + const choose = (pickedServerId: string | null, activeServerId: string | null) => + resolveUsageHostId({ pickedServerId, activeServerId, hosts }); + + it("reads the picked host over the active workspace's host", () => { + expect(choose("a", "b")).toBe("a"); + }); + + it("reads the active workspace's host when nothing usable is picked", () => { + expect(choose(null, "b")).toBe("b"); + expect(choose("offline", "b")).toBe("b"); + expect(choose("old", "b")).toBe("b"); }); it("falls back to the first connected host that reports usage", () => { - expect(resolveUsageHostId(null, hosts)).toBe("a"); - expect(resolveUsageHostId("offline", hosts)).toBe("a"); - expect(resolveUsageHostId("old", hosts)).toBe("a"); + expect(choose(null, null)).toBe("a"); + expect(choose(null, "offline")).toBe("a"); + expect(choose(null, "old")).toBe("a"); }); it("has no host when none reports usage", () => { - expect(resolveUsageHostId("old", hosts.slice(0, 2))).toBeNull(); + expect( + resolveUsageHostId({ + pickedServerId: "old", + activeServerId: "old", + hosts: hosts.slice(0, 2), + }), + ).toBeNull(); }); }); describe("resolveUsageScreenHostId", () => { - it("keeps the user's pick while it stays connected", () => { - expect(resolveUsageScreenHostId({ selectedServerId: "old", activeServerId: "b", hosts })).toBe( + it("shows the picked host while it stays connected, even one that cannot report usage", () => { + expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( "old", ); expect( - resolveUsageScreenHostId({ selectedServerId: "offline", activeServerId: "b", hosts }), + resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), ).toBe("b"); }); - it("defaults to the host the sidebar row reads", () => { - for (const activeServerId of ["b", null, "old", "offline"]) { - expect(resolveUsageScreenHostId({ selectedServerId: null, activeServerId, hosts })).toBe( - resolveUsageHostId(activeServerId, hosts), - ); + 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(resolveUsageScreenHostId({ selectedServerId: null, activeServerId: "old", hosts })).toBe( - "a", - ); }); it("shows the first connected host when none reports usage, so the screen says to update it", () => { expect( resolveUsageScreenHostId({ - selectedServerId: null, + pickedServerId: null, activeServerId: null, hosts: hosts.slice(0, 2), }), ).toBe("old"); expect( - resolveUsageScreenHostId({ selectedServerId: null, activeServerId: null, hosts: [] }), + resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), ).toBeNull(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ad5fe99c2f..0e44ddae18 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -97,34 +97,32 @@ export interface UsageHost { supportsUsage: boolean; } -/** - * The host usage shows by default, on the sidebar row and the Usage screen: the active workspace's - * host if it reports usage, else the first host that does. - */ -export function resolveUsageHostId( - activeServerId: string | null, - hosts: readonly UsageHost[], -): string | null { - const reporting = hosts.filter((host) => host.isConnected && host.supportsUsage); - const active = reporting.find((host) => host.serverId === activeServerId); - return (active ?? reporting[0])?.serverId ?? null; +/** Where usage looks for its host: the user's saved pick, then the workspace they are in. */ +export interface UsageHostChoice { + pickedServerId: string | null; + activeServerId: string | null; + hosts: readonly UsageHost[]; } /** - * The host the Usage screen shows: the user's pick while it stays connected, else the default - * host. With no host reporting usage, the first connected host, so the screen says to update it. + * The host the sidebar Usage row reads: the picked host, else the active workspace's host, else the + * first host. Each only while it is connected and reports usage. */ -export function resolveUsageScreenHostId(input: { - selectedServerId: string | null; - activeServerId: string | null; - hosts: readonly UsageHost[]; -}): string | null { - const connected = input.hosts.filter((host) => host.isConnected); - const selected = connected.find((host) => host.serverId === input.selectedServerId); +export function resolveUsageHostId(choice: UsageHostChoice): string | null { + const reporting = choice.hosts.filter((host) => host.isConnected && host.supportsUsage); + const find = (serverId: string | null) => reporting.find((host) => host.serverId === serverId); return ( - selected?.serverId ?? - resolveUsageHostId(input.activeServerId, input.hosts) ?? - connected[0]?.serverId ?? - null + (find(choice.pickedServerId) ?? find(choice.activeServerId) ?? reporting[0])?.serverId ?? 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 + * connected host. + */ +export function resolveUsageScreenHostId(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/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index 0580d3ccfc..af6d48cb12 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -40,10 +40,50 @@ function preferences( pinned: UsagePreferences["pinned"], displayAs: UsagePreferences["displayAs"] = "used", ): UsagePreferences { - return { displayAs, pinned }; + return { displayAs, pinned, serverId: null }; } describe("resolvePinnedUsage", () => { + it("defaults to the first window with a percent for each source account", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [ + { id: "empty", label: "Empty" }, + { id: "weekly", label: "Weekly", usedPct: 90 }, + ], + }); + const empty = report({ + sourceId: "empty", + sourceLabel: "Empty", + windows: [{ id: "empty", label: "Empty" }], + }); + expect( + resolvePinnedUsage([claude, codex, work, empty], preferences([])).map((item) => [ + item.key, + item.percentText, + ]), + ).toEqual([ + ["claude:default/five-hour", "31%"], + ["codex:default/weekly", "12%"], + ["claude:work/weekly", "90%"], + ]); + }); + + it("pins replace defaults, and removing all pins restores defaults", () => { + const reports = [claude, codex]; + expect( + resolvePinnedUsage(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( + (item) => item.key, + ), + ).toEqual(["claude:default/weekly"]); + expect(resolvePinnedUsage(reports, preferences([])).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "codex:default/weekly", + ]); + }); + it("shows pinned windows in pin order, not report order", () => { const items = resolvePinnedUsage( [claude, codex], @@ -54,11 +94,16 @@ describe("resolvePinnedUsage", () => { ); expect(items).toEqual([ - { key: "codex:default/weekly", icon: null, label: "Codex Weekly", percentText: "12%" }, + { + key: "codex:default/weekly", + icon: null, + label: "Codex Weekly 12% used", + percentText: "12%", + }, { key: "claude:default/five-hour", icon: "", - label: "Claude 5-hour", + label: "Claude 5-hour 31% used", percentText: "31%", }, ]); @@ -76,7 +121,11 @@ describe("resolvePinnedUsage", () => { ), ); - expect(items.map((item) => item.percentText)).toEqual(["69% left", "88% left"]); + expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]); + expect(items.map((item) => item.label)).toEqual([ + "Claude 5-hour 69% left", + "Codex Weekly 88% left", + ]); }); it("leaves out a pinned window that no report has, or that reports no percent", () => { @@ -116,8 +165,8 @@ describe("resolvePinnedUsage", () => { ); expect(items.map((item) => [item.label, item.percentText])).toEqual([ - ["Claude (personal) 5-hour", "10%"], - ["Claude (work) 5-hour", "90%"], + ["Claude (personal) 5-hour 10% used", "10%"], + ["Claude (work) 5-hour 90% used", "90%"], ]); }); }); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 7d348ffcd2..11cf0ecabf 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,13 +1,13 @@ -import { formatDisplayPct } from "./format"; +import { formatPct } from "./format"; import { displayPercent } from "./model"; import type { UsagePreferences } from "./preferences"; import type { UsageReportEntry } from "./types"; -/** One pinned window of one account, as the sidebar Usage item shows it. */ +/** One summary window of one account, as the sidebar Usage item shows it. */ export interface PinnedUsageWindow { key: string; icon: string | null; - /** Source, account when it has one, and window: "Claude (work) 5-hour". */ + /** Accessible description, including the source, account, window and percent meaning. */ label: string; percentText: string; } @@ -17,28 +17,40 @@ function describe(entry: UsageReportEntry, windowLabel: string): string { return `${entry.sourceLabel}${account} ${windowLabel}`; } -/** - * The pinned windows the Usage item shows: pins in pin order, and within a pin one item per account of the source - * in report order. A pinned window a report lacks, or reports no percent for, is left out. - */ +/** Pins replace defaults; without pins, summarize each account's first window with a percent. */ export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, ): PinnedUsageWindow[] { - return preferences.pinned.flatMap((pin) => - reports.flatMap((entry) => { - if (entry.sourceId !== pin.sourceId) return []; - const window = entry.report.windows.find((candidate) => candidate.id === pin.windowId); - const percent = window ? displayPercent(window, preferences.displayAs) : null; - if (!window || percent == null) return []; - return [ - { - key: `${entry.id}/${window.id}`, - icon: entry.icon ?? null, - label: describe(entry, window.label), - percentText: formatDisplayPct(percent, preferences.displayAs), - }, - ]; - }), - ); + const selections = + preferences.pinned.length === 0 + ? reports.map((entry) => ({ + entry, + window: entry.report.windows.find( + (window) => displayPercent(window, preferences.displayAs) !== null, + ), + })) + : preferences.pinned.flatMap((pin) => + reports + .filter((entry) => entry.sourceId === pin.sourceId) + .map((entry) => ({ + entry, + window: entry.report.windows.find((window) => window.id === pin.windowId), + })), + ); + return selections.flatMap(({ entry, window }) => { + if (!window) return []; + const percent = displayPercent(window, preferences.displayAs); + if (percent === null) return []; + const percentText = formatPct(percent); + const meaning = preferences.displayAs === "remaining" ? "left" : "used"; + return [ + { + key: `${entry.id}/${window.id}`, + icon: entry.icon ?? null, + label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + percentText, + }, + ]; + }); } diff --git a/packages/app/src/usage/preferences.test.ts b/packages/app/src/usage/preferences.test.ts index b060632067..391ec05cd3 100644 --- a/packages/app/src/usage/preferences.test.ts +++ b/packages/app/src/usage/preferences.test.ts @@ -3,7 +3,9 @@ import { DEFAULT_USAGE_PREFERENCES, isUsagePinned, setUsageDisplayAs, + setUsageHost, toggleUsagePin, + UsagePreferencesSchema, } from "./preferences"; const claudeFiveHour = { sourceId: "claude", windowId: "five-hour" }; @@ -23,17 +25,33 @@ describe("usage preferences", () => { it("unpins a pinned window and keeps the others in order", () => { const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }]; - const unpinned = toggleUsagePin({ displayAs: "used", pinned: three }, claudeFiveHour); + const unpinned = toggleUsagePin( + { displayAs: "used", pinned: three, serverId: null }, + claudeFiveHour, + ); expect(unpinned.pinned).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]); }); it("switches between used and remaining without touching pins", () => { - const preferences = { displayAs: "used" as const, pinned: [codexWeekly] }; + const preferences = { displayAs: "used" as const, pinned: [codexWeekly], serverId: null }; expect(setUsageDisplayAs(preferences, "remaining")).toEqual({ displayAs: "remaining", pinned: [codexWeekly], + serverId: null, }); }); + + it("remembers the picked host without touching pins", () => { + expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pinned: [codexWeekly] }, "server")).toEqual( + { displayAs: "used", pinned: [codexWeekly], serverId: "server" }, + ); + }); + + it("reads preferences saved before hosts could be picked as no pick", () => { + expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual( + { displayAs: "remaining", pinned: [codexWeekly], serverId: null }, + ); + }); }); diff --git a/packages/app/src/usage/preferences.ts b/packages/app/src/usage/preferences.ts index 55c5ee7d8e..c25cf6e1c7 100644 --- a/packages/app/src/usage/preferences.ts +++ b/packages/app/src/usage/preferences.ts @@ -13,14 +13,21 @@ export interface UsagePin { export interface UsagePreferences { displayAs: UsageDisplayAs; pinned: UsagePin[]; + /** The host the user picked to show usage for; null until they pick one. */ + serverId: string | null; } -export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { displayAs: "used", pinned: [] }; +export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { + displayAs: "used", + pinned: [], + serverId: null, +}; export const UsagePreferencesSchema = z .object({ displayAs: z.enum(["used", "remaining"]).catch("used"), pinned: z.array(z.object({ sourceId: z.string(), windowId: z.string() })).catch([]), + serverId: z.string().nullable().catch(null), }) .catch(DEFAULT_USAGE_PREFERENCES); @@ -46,3 +53,7 @@ export function setUsageDisplayAs( ): UsagePreferences { return { ...preferences, displayAs }; } + +export function setUsageHost(preferences: UsagePreferences, serverId: string): UsagePreferences { + return { ...preferences, serverId }; +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index c4993b8b2e..9b9e9a80ef 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -25,15 +25,14 @@ const NO_REPORTS: UsageReportEntry[] = []; const NO_ITEMS: PinnedUsageWindow[] = []; /** - * The sidebar footer's usage entry: each pinned window's source icon and percent, or a plain - * "Usage" row while no pinned window has data. Pressing it opens the Usage screen; on compact + * The sidebar footer's usage entry: each summary window's source icon and percent, or a plain + * "Usage" row while no summary window has data. Pressing it opens the Usage screen; on compact * layouts it opens the usage sheet instead. */ export function UsageSidebarItem() { const { preferences, display } = useUsagePreferences(); const serverId = useUsageHostId(); - // Without pins there is nothing to summarize, so the host is not asked for reports. - if (!serverId || preferences.pinned.length === 0) { + if (!serverId) { return ; } return ( @@ -84,7 +83,7 @@ function UsageEntry({ const isCompact = useIsCompactFormFactor(); const openUsageScreen = useOpenUsageScreen(); const [open, setOpen] = useState(false); - // The sheet mounts on first open, so an unopened sidebar never asks the host for reports. + // The sheet mounts on first open; the summary already owns the report query. const [sheetMounted, setSheetMounted] = useState(false); // Without a host there are no reports to show, so compact goes to the screen, which says so. const usesSheet = isCompact && serverId !== null; @@ -170,7 +169,7 @@ function HostUsageSheet({ } function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string { - return `${label}: ${items.map((item) => `${item.label} ${item.percentText}`).join(", ")}`; + return `${label}: ${items.map((item) => item.label).join(", ")}`; } function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 0f6d7f1e07..3554fb8a0e 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,3 +1,5 @@ +import { legacyUsageIcon } from "./legacy-usage-icons.js"; +import { readFileSync } from "node:fs"; import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { @@ -6573,6 +6575,22 @@ test("sends provider.usage.list.request and resolves provider.usage.list.respons }); }); +test.each(["claude", "codex", "copilot", "cursor", "zai", "grok", "kimi", "minimax"])( + "released-host %s icon matches its source plugin", + (sourceId) => { + const icon = readFileSync( + new URL(`../../../plugins/${sourceId}-usage-source/icon.svg`, import.meta.url), + "utf8", + ); + expect(legacyUsageIcon(sourceId)).toBe(icon); + }, +); + +test("unknown released-host sources have no icon", () => { + expect(legacyUsageIcon("unknown")).toBeUndefined(); + expect(legacyUsageIcon("toString")).toBeUndefined(); +}); + test("maps released-host usage reports and filters report IDs", async () => { const mock = createMockTransport(); const client = new DaemonClient({ @@ -6608,11 +6626,19 @@ test("maps released-host usage reports and filters report IDs", async () => { error: "unavailable", }, ]; + const extraProviders = ["copilot", "cursor", "zai", "grok", "kimi", "minimax", "unknown"].map( + (providerId) => Object.assign({}, providers[0]!, { providerId, displayName: providerId }), + ); + providers.push(...extraProviders); const reports = [ { id: "claude", sourceId: "claude", sourceLabel: "Claude", + icon: readFileSync( + new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url), + "utf8", + ), account: {}, fetchedAt: "2026-09-30T00:00:00.000Z", report: { @@ -6628,6 +6654,10 @@ test("maps released-host usage reports and filters report IDs", async () => { id: "codex", sourceId: "codex", sourceLabel: "Codex", + icon: readFileSync( + new URL("../../../plugins/codex-usage-source/icon.svg", import.meta.url), + "utf8", + ), account: {}, fetchedAt: "2026-09-29T00:00:00.000Z", report: { @@ -6640,6 +6670,16 @@ test("maps released-host usage reports and filters report IDs", async () => { }, }, ]; + reports.push( + ...extraProviders.map((provider) => + Object.assign({}, reports[0]!, { + id: provider.providerId, + sourceId: provider.providerId, + sourceLabel: provider.displayName, + icon: legacyUsageIcon(provider.providerId), + }), + ), + ); for (const reportIds of [undefined, ["codex", "missing"], []]) { const result = client.listUsageReports({ requestId: "legacy-usage", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ba1e09e7cb..145aad7447 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; import { @@ -5281,6 +5282,7 @@ export class DaemonClient { id: provider.providerId, sourceId: provider.providerId, sourceLabel: provider.displayName, + icon: legacyUsageIcon(provider.providerId), account: {}, fetchedAt: provider.fetchedAt ?? payload.fetchedAt, report: { diff --git a/packages/client/src/legacy-usage-icons.ts b/packages/client/src/legacy-usage-icons.ts new file mode 100644 index 0000000000..227bde8134 --- /dev/null +++ b/packages/client/src/legacy-usage-icons.ts @@ -0,0 +1,21 @@ +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +// Released hosts omit source icons. Delete these copies with the list conversion. +const ICONS: Readonly> = { + claude: + '\n', + codex: + '\n', + copilot: + '\n', + cursor: + '\n \n\n', + zai: 'Z.ai\n', + grok: '\n', + kimi: '\n \n\n', + minimax: + '\n', +}; + +export function legacyUsageIcon(sourceId: string): string | undefined { + return Object.hasOwn(ICONS, sourceId) ? ICONS[sourceId] : undefined; +}