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.
This commit is contained in:
Mohamed Boudra
2026-09-30 21:09:07 +01:00
committed by GitHub
parent 695ecb2812
commit 1f5f5d384e
31 changed files with 717 additions and 365 deletions
@@ -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();
@@ -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]);
});
+89 -25
View File
@@ -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<string> {
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);
}
});
});
+15 -16
View File
@@ -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 = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
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 }) => {
@@ -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 = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
const codexPath = /<path[^>]* 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");
});
@@ -47,7 +47,7 @@ async function openAddProjectFlowSurface(
page: Page,
expectedPage: "host" | "method",
): Promise<void> {
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);
}
@@ -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<v
}
const FOOTER_ICON_TEST_IDS = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-import-session",
"sidebar-help",
"sidebar-settings",
];
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
/** The labeled Add project button on the left, then the four fixed icons on the right. */
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
@@ -232,10 +234,49 @@ export async function expectFooterIconRow(page: Page): Promise<void> {
),
);
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<void> {
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<void> {
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<void> {
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`) });
}
@@ -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<void>;
}
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 | (() => UsageListResponse)>;
lists?: Array<UsageListResponse | ((request: UsageListRequest) => 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<string, unknown> |
return envelope.message as Record<string, unknown>;
}
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<string, unknown> };
@@ -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<UsageReportsFixture> {
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);
});
});
@@ -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 / <host>" 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>" 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,
@@ -24,7 +24,7 @@ export {
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
interface HostPickerHost {
export interface HostPickerHost {
serverId: string;
label: string;
}
@@ -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<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value;
const openPicker = useCallback(() => setOpen(true), []);
return (
<HostPicker
hosts={hosts}
value={value}
onSelect={onSelect}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title={title}
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}: ${selectedLabel}`}
testID={testID}
onPress={openPicker}
style={styles.trigger}
>
<Text numberOfLines={1} style={styles.label}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
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,
},
}));
+69 -57
View File
@@ -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({
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<TooltipContent side="top" align="center" offset={8} testID={`${testID}-tooltip`}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
@@ -367,7 +368,7 @@ function FooterAddProjectButton({
}}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<TooltipContent side="top" align="center" offset={8} testID="sidebar-add-project-tooltip">
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
@@ -473,37 +474,39 @@ function SidebarFooter({
const settingsKeys = useShortcutKeys("toggle-settings");
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
</View>
);
@@ -516,21 +519,24 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
const visibleItems = items.filter((item) => item.visible);
if (visibleItems.length === 0) return null;
return (
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
{visibleItems.map((item) =>
item.kind === "plugin" ? (
<PluginSidebarItem
key={item.key}
group={item.group}
section="footer"
fallbackAnchorRef={rowsRef}
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
),
)}
</View>
<>
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
{visibleItems.map((item) =>
item.kind === "plugin" ? (
<PluginSidebarItem
key={item.key}
group={item.group}
section="footer"
fallbackAnchorRef={rowsRef}
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
),
)}
</View>
<SidebarSeparator testID="sidebar-footer-separator" />
</>
);
}
@@ -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,
@@ -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 <View style={styles.line} />;
export function SidebarSeparator({ testID }: { testID?: string } = {}) {
return <View style={styles.line} testID={testID} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -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,
});
});
+8 -9
View File
@@ -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 (
<HostSwitcher
<HostFilter
hosts={hosts}
value={serverId}
onSelect={selectHost}
title="Choose plugin host"
accessibilityLabel="Plugin host"
testID="plugin-host-switcher"
selectedHost={serverId}
onSelectHost={selectHost}
includeAllHost={false}
triggerTestID="plugin-host-filter-trigger"
/>
);
}
@@ -192,7 +191,7 @@ export function PluginSurfaceScreen() {
() => (
<>
{identity ? (
<PluginHostSwitcher
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
@@ -179,7 +179,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen
);
}
/** Settings > 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 (
<>
@@ -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 },
+2 -2
View File
@@ -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"],
+15 -11
View File
@@ -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 (
<View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 0 ? (
<HostSwitcher
{hostSelection && hostSelection.hosts.length > 1 ? (
<HostFilter
hosts={hostSelection.hosts}
value={hostSelection.serverId}
onSelect={hostSelection.onSelect}
title={usageCopy.host}
accessibilityLabel={usageCopy.host}
testID="usage-host-switcher"
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
) : 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,
-1
View File
@@ -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;
+34 -14
View File
@@ -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<string | null>(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 };
}
+32 -19
View File
@@ -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();
});
});
+22 -24
View File
@@ -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;
}
+55 -6
View File
@@ -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: "<svg/>",
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%"],
]);
});
});
+35 -23
View File
@@ -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,
},
];
});
}
+20 -2
View File
@@ -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 },
);
});
});
+12 -1
View File
@@ -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 };
}
+5 -6
View File
@@ -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 <UsageEntry serverId={serverId} items={NO_ITEMS} display={display} />;
}
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 }) {
+40
View File
@@ -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",
+2
View File
@@ -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: {
+21
View File
@@ -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<Record<string, string>> = {
claude:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>\n',
codex:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"/></svg>\n',
copilot:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 416" fill="currentColor"><path d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"\n fill-rule="nonzero"/><path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z"/></svg>\n',
cursor:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
zai: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
grok: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>\n',
kimi: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>\n</svg>\n',
minimax:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"/></svg>\n',
};
export function legacyUsageIcon(sourceId: string): string | undefined {
return Object.hasOwn(ICONS, sourceId) ? ICONS[sourceId] : undefined;
}