From 1f5f5d384e9409240c11fc4491fcdb247ea4bc14 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 30 Sep 2026 21:09:07 +0100 Subject: [PATCH] Sidebar usage shows default percents with logos, and remembers the picked host (#5786) * fix(app): show default usage windows when no windows are pinned * fix(app): show only percentages in sidebar usage summaries * fix(client): restore usage source logos for released hosts * fix(app): restore the labeled footer line below sidebar items * fix(app): use History's host filter to pick the usage and plugin host The Usage screen, the compact usage sheet and plugin screens used a separate HostSwitcher trigger. They now use HostFilter without "All hosts", as the label manager does, and hide it with one host, as History does. * fix(app): save the usage host pick and show it in the sidebar summary Picking a host on the Usage screen or usage sheet was view state: it reset when the view closed, and the sidebar summary never read it. The pick is now a device-local usage preference. The summary and the screen show the picked host while it is connected, else the active workspace's host, else the first host. The usage fixture answers by request, since the summary and the screen each load reports and only a card's Refresh forces one. * test(app): expect plugin footer rows above the footer bottom line Add project moved to the bottom line with the icons, so footer rows sit above it, spanning Add project to Settings. * test(app): expect the saved usage host in stored settings * test(app): let usage settings specs share reports with the sidebar summary The sidebar summary loads reports on app start, so the settings specs answer by request instead of by order and read percents from the settings card. --- .../e2e/browser/plugin-sidebar-items.spec.ts | 13 +- .../e2e/browser/sidebar-nav-settings.spec.ts | 57 ++++++++ packages/app/e2e/browser/usage-screen.spec.ts | 114 ++++++++++++---- .../app/e2e/browser/usage-settings.spec.ts | 31 +++-- .../e2e/browser/usage-sidebar-item.spec.ts | 83 ++++++++---- .../e2e/support/helpers/add-project-flow.ts | 2 +- .../support/helpers/sidebar-nav-settings.ts | 45 ++++++- .../app/e2e/support/helpers/usage-reports.ts | 78 ++++++++--- .../app/src/components/hosts/host-filter.tsx | 10 +- .../app/src/components/hosts/host-picker.tsx | 2 +- .../src/components/hosts/host-switcher.tsx | 83 ------------ packages/app/src/components/left-sidebar.tsx | 126 ++++++++++-------- .../components/sidebar/sidebar-separator.tsx | 4 +- .../src/hooks/use-settings/storage.test.ts | 10 +- packages/app/src/plugins/surface-screen.tsx | 17 ++- .../settings/sidebar/sidebar-nav-section.tsx | 2 +- packages/app/src/sidebar-nav/model.test.ts | 1 + packages/app/src/sidebar-nav/model.ts | 4 +- packages/app/src/usage/controls.tsx | 26 ++-- packages/app/src/usage/copy.ts | 1 - packages/app/src/usage/hosts.ts | 48 +++++-- packages/app/src/usage/model.test.ts | 51 ++++--- packages/app/src/usage/model.ts | 46 +++---- packages/app/src/usage/pinned.test.ts | 61 ++++++++- packages/app/src/usage/pinned.ts | 58 ++++---- packages/app/src/usage/preferences.test.ts | 22 ++- packages/app/src/usage/preferences.ts | 13 +- packages/app/src/usage/sidebar-item.tsx | 11 +- packages/client/src/daemon-client.test.ts | 40 ++++++ packages/client/src/daemon-client.ts | 2 + packages/client/src/legacy-usage-icons.ts | 21 +++ 31 files changed, 717 insertions(+), 365 deletions(-) delete mode 100644 packages/app/src/components/hosts/host-switcher.tsx create mode 100644 packages/client/src/legacy-usage-icons.ts 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; +}