diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index 6f546078bf..a923971d09 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -100,6 +100,40 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } +function installReversedUsageRefresh(page: Page) { + const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)]; + const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)]; + return installUsageReportsFixture(page, { + lists: [(request) => (request.forceRefresh ? refreshed : initial)], + }); +} + +async function openUsageSummaryAtWidth(page: Page, width: number) { + await page.setViewportSize({ width, height: 900 }); + await gotoAppShell(page); + if (width === 390) await openCompactSidebar(page); + await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]); + await openUsageFromItem(page); +} + +async function expectUsageProviderOrder(page: Page, summary: string[]) { + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([ + "opencode plan", + "openai plan", + ]); + await expectPinnedUsage(page, summary); +} + +async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) { + const requestCount = usage.listRequests().length; + await refreshAllUsage(page); + await usage.waitForListRequests(requestCount + 1); + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText("41%", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled(); +} + test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, @@ -152,6 +186,21 @@ test.describe("usage modal", () => { await expect(group.getByTestId("usage-freshness")).toHaveCount(0); }); + for (const width of [1440, 390]) { + test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({ + page, + }) => { + const usage = await installReversedUsageRefresh(page); + await openUsageSummaryAtWidth(page, width); + await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-before-refresh`); + + await refreshUsageAndWaitForReports(page, usage); + await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-after-refresh`); + }); + } + test("refreshes one report from its card", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index fb738b3cb5..27fac6691c 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -30,7 +30,7 @@ import { openUsageFromIcon, openUsageFromItem, pinRow, - scrollUsage, + scrollUsageToWindow, setSummaryInSidebar, openUsageOptions, showUsageAs, @@ -146,7 +146,9 @@ test.describe("Usage item", () => { compact: { viewport: COMPACT, showSidebar: openCompactSidebar }, }; for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) { - test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => { + test(`long usage reports scroll between their first and last windows on ${size}`, async ({ + page, + }) => { test.setTimeout(120_000); const source = await installTallUsageSource(); try { @@ -161,10 +163,10 @@ test.describe("Usage item", () => { await waitForSettledPosition(content); await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); await qaScreenshot(page, `${size}-usage-scroll-top`); - await scrollUsage(page, 5_000); + await scrollUsageToWindow(page, "Window 20"); await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); await qaScreenshot(page, `${size}-usage-scroll-bottom`); - await scrollUsage(page, -5_000); + await scrollUsageToWindow(page, "Window 1"); await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); } finally { await source.cleanup(); diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 8cc3c07a00..95c6a4d689 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -212,6 +212,14 @@ export async function scrollUsage(page: Page, deltaY: number): Promise { await page.mouse.wheel(0, deltaY); } +/** Reach a window with the wheel even when other reports follow its card. */ +export async function scrollUsageToWindow(page: Page, label: string): Promise { + const target = await usageModal(page).getByText(label, { exact: true }).boundingBox(); + const viewport = page.viewportSize(); + if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it."); + await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2); +} + /** The modal's own Close button. */ export async function closeUsage(page: Page): Promise { await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index ad293bb23e..a2c493619d 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -257,6 +257,22 @@ describe("settleReports", () => { expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); }); + it("keeps the displayed order when providers finish a refresh in reverse order", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + const streamed = upsertReport(upsertReport([alpha, beta], beta), refreshed); + expect(settleReports(streamed, [beta, refreshed])).toEqual([refreshed, beta]); + }); + + it("drops missing reports and appends new ones without moving surviving reports", () => { + const gamma = entry({ sourceId: "gamma" }); + const delta = entry({ sourceId: "delta" }); + expect(settleReports([alpha, beta, gamma], [delta, gamma, alpha])).toEqual([ + alpha, + gamma, + delta, + ]); + }); + it("takes the finished list as is when nothing was shown yet", () => { expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index a32da7ea4d..ed8a39163a 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -82,17 +82,24 @@ export function upsertReport( } /** - * The list a finished request leaves: its reports, dropping any the host no longer has, except - * that a copy on screen fetched after the request's copy stays. + * Keep displayed reports in place: the finished list is in fetch-completion order, which can + * change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies. */ export function settleReports( shown: readonly UsageReportEntry[] | undefined, finished: readonly UsageReportEntry[], ): UsageReportEntry[] { - return finished.map((report) => { - const copy = shown?.find((entry) => entry.id === report.id); - return copy ? laterFetched(copy, report) : report; - }); + const remaining = new Map(finished.map((report) => [report.id, report])); + const settled: UsageReportEntry[] = []; + for (const report of shown ?? []) { + const arriving = remaining.get(report.id); + if (!arriving) { + continue; + } + settled.push(laterFetched(report, arriving)); + remaining.delete(report.id); + } + return [...settled, ...remaining.values()]; } export interface UsageQueryState {